🎖️GitЯра🎖️
Commit 09555a43984e44f937e00c8a33c2104028599627
Parents : d12fc8e
Author : Jeremiah K <17190268+jeremiah-k@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-07-22T18:53:43-05:00
Committer : GitHub <noreply@github.com>
Date : 2026-07-22T23:53:43Z
fix(node): reconcile stale identity replays after renumbering (#6259)
Changes
14 files changed, 2661 insertions(+), 115 deletions(-)
Diff
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt
index c9438ebbf6..ea62007689 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoReadDataSource.kt
@@ -37,4 +37,11 @@ interface NodeInfoReadDataSource {
suspend fun getNodesOlderThan(lastHeard: Int): List<NodeEntity>
suspend fun getUnknownNodes(): List<NodeEntity>
+
+ /**
+ * One-shot snapshot of every node row in the currently selected database. Bypasses the process-wide
+ * [nodeDBbyNumFlow] cache so callers see the live DB state at invocation time rather than a `stateIn` value that
+ * may belong to a previous transport.
+ */
+ suspend fun getNodeDbSnapshot(): Map<Int, NodeWithRelations>
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt
index 1182888777..7fdd69c2b7 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoReadDataSource.kt
@@ -55,4 +55,7 @@ class SwitchingNodeInfoReadDataSource(private val dbManager: DatabaseProvider) :
override suspend fun getUnknownNodes(): List<NodeEntity> =
dbManager.withReadDb { it.nodeInfoDao().getUnknownNodes() }
+
+ override suspend fun getNodeDbSnapshot(): Map<Int, NodeWithRelations> =
+ dbManager.withReadDb { it.nodeInfoDao().nodeDBbyNumSnapshot() }
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
index e4f8e752ba..7640e2285a 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt
@@ -296,7 +296,7 @@ class MeshConfigFlowManagerImpl(
if (!isActiveSession(session)) return
if (removedNums.isNotEmpty()) {
Logger.i { "Config install migrated ${removedNums.size} stale node identit(y/ies)" }
- removedNums.forEach(nodeManager::removeByNodenum)
+ nodeManager.applyTrustedIdentityMigrations(removedNums)
}
val published =
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
index 78a9b09f57..4b286c02b0 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt
@@ -20,17 +20,19 @@ import co.touchlab.kermit.Logger
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.update
import kotlinx.collections.immutable.PersistentMap
+import kotlinx.collections.immutable.PersistentSet
import kotlinx.collections.immutable.persistentMapOf
+import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okio.ByteString
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.common.util.clampTimestampToNow
+import org.meshtastic.core.common.util.crc32
import org.meshtastic.core.common.util.handledLaunch
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
@@ -57,8 +59,29 @@ import kotlinx.coroutines.flow.update as updateStateFlow
import org.meshtastic.proto.NodeInfo as ProtoNodeInfo
import org.meshtastic.proto.Position as ProtoPosition
+/**
+ * Resolves a validated public-key correlation hint from a raw [ByteString]. Returns null unless the key is exactly
+ * [Node.PUBLIC_KEY_SIZE] bytes and is not [Node.ERROR_BYTE_STRING]. The hint is used only for in-memory correlation; it
+ * is not a trusted hardware identity and never authorizes durable node migration or deletion.
+ */
+private fun resolveValidatedPublicKeyHint(key: ByteString?): ByteString? {
+ if (key == null || key.size != Node.PUBLIC_KEY_SIZE || key == Node.ERROR_BYTE_STRING) return null
+ return key
+}
+
+/** Single source of truth for stored-node key precedence: the primary field wins over the embedded [User] field. */
+private fun nodePublicKeyHint(node: Node): ByteString? =
+ resolveValidatedPublicKeyHint(node.publicKey) ?: resolveValidatedPublicKeyHint(node.user.public_key)
+
+/** Short one-way diagnostic value that cannot disclose a raw public-key prefix. */
+private const val KEY_FINGERPRINT_HEX_LENGTH = 8
+
+internal fun publicKeyLogFingerprint(key: ByteString): String = key.sha256().hex().take(KEY_FINGERPRINT_HEX_LENGTH)
+
+private val DEFAULT_NODE_NAME_REGEX = Regex("^Meshtastic [0-9a-fA-F]{4}$")
+
/** Implementation of [NodeManager] that maintains an in-memory database of the mesh. */
-@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod")
+@Suppress("LongParameterList", "TooManyFunctions", "CyclomaticComplexMethod", "LargeClass")
@Single(binds = [NodeManager::class, NodeIdLookup::class])
class NodeManagerImpl(
private val nodeRepository: NodeRepository,
@@ -67,59 +90,172 @@ class NodeManagerImpl(
@Named("ServiceScope") private val scope: CoroutineScope,
) : NodeManager {
- // Fixed stripes bound mutex lifetime while preserving same-node ordering; hash collisions only reduce parallelism.
+ // Fixed stripes bound mutex lifetime while preserving same-node persistence ordering. Hash collisions only reduce
+ // parallelism; they never weaken ordering for a node.
private val nodePersistenceLanes = List(NODE_PERSISTENCE_LANE_COUNT) { Mutex() }
private fun persistenceLane(nodeNum: Int): Mutex =
nodePersistenceLanes[(nodeNum.toLong() and Int.MAX_VALUE.toLong()).toInt() % nodePersistenceLanes.size]
- // Two indices over the same node set: byNum is the canonical store (mesh-level identifier), byId is a secondary
- // O(1) lookup for the user-facing hex string. Both are held in a single atomic ref so updates are observed
- // consistently — concurrent readers never see an entry present in one index but not the other.
- private data class NodeIndex(
+ /**
+ * Resolves a validated public-key correlation hint from a stored [Node], preferring [Node.publicKey] and falling
+ * back to [User.public_key] only when the primary field is not itself a valid hint.
+ */
+ internal fun resolveNodePublicKeyHint(node: Node): ByteString? = nodePublicKeyHint(node)
+
+ // byNum is the canonical in-memory store. Candidate-number reverse sets make normal put/remove and keyed packet
+ // handling proportional to the affected ID/key candidates rather than the entire mesh. byId stores the selected
+ // deterministic representative for O(1) user-ID lookup.
+ internal data class NodeIndex(
val byNum: PersistentMap<Int, Node> = persistentMapOf(),
val byId: PersistentMap<String, Node> = persistentMapOf(),
+ val candidateNumsById: PersistentMap<String, PersistentSet<Int>> = persistentMapOf(),
+ val candidateNumsByPublicKey: PersistentMap<ByteString, PersistentSet<Int>> = persistentMapOf(),
) {
- fun put(num: Int, node: Node): NodeIndex {
+ fun put(num: Int, node: Node, preferredNum: Int? = null): NodeIndex {
val previous = byNum[num]
- var nextById = byId
- // If the user.id changed (e.g. firmware reassigned the hex id) drop the stale id entry.
- if (previous != null && previous.user.id.isNotEmpty() && previous.user.id != node.user.id) {
- nextById = nextById.removing(previous.user.id)
+ val nextByNum = byNum.putting(num, node)
+ var nextCandidatesById = candidateNumsById
+ var nextCandidatesByPublicKey = candidateNumsByPublicKey
+
+ previous
+ ?.user
+ ?.id
+ ?.takeIf { it.isNotEmpty() }
+ ?.let { id -> nextCandidatesById = nextCandidatesById.removingCandidate(id, num) }
+ previous
+ ?.let { nodePublicKeyHint(it) }
+ ?.let { key -> nextCandidatesByPublicKey = nextCandidatesByPublicKey.removingCandidate(key, num) }
+ node.user.id
+ .takeIf { it.isNotEmpty() }
+ ?.let { id -> nextCandidatesById = nextCandidatesById.addingCandidate(id, num) }
+ nodePublicKeyHint(node)?.let { key ->
+ nextCandidatesByPublicKey = nextCandidatesByPublicKey.addingCandidate(key, num)
}
- if (node.user.id.isNotEmpty()) {
- nextById = nextById.putting(node.user.id, node)
+
+ var nextById = byId
+ val affectedIds = setOfNotNull(previous?.user?.id, node.user.id).filter { it.isNotEmpty() }
+ for (id in affectedIds) {
+ val representative = chooseRepresentativeNum(nextCandidatesById[id].orEmpty(), nextByNum, preferredNum)
+ nextById =
+ if (representative == null) {
+ nextById.removing(id)
+ } else {
+ nextById.putting(id, checkNotNull(nextByNum[representative]))
+ }
}
- return NodeIndex(byNum = byNum.putting(num, node), byId = nextById)
+ return NodeIndex(nextByNum, nextById, nextCandidatesById, nextCandidatesByPublicKey)
}
- fun remove(num: Int): NodeIndex {
+ /**
+ * Removes [num] from both indices. When the removed node's user ID was the [byId] representative and another
+ * surviving node shares that ID, [preferredNum] wins when present; otherwise deterministic ordering selects the
+ * replacement.
+ */
+ fun remove(num: Int, preferredNum: Int? = null): NodeIndex {
val previous = byNum[num] ?: return this
- return NodeIndex(
- byNum = byNum.removing(num),
- byId = if (previous.user.id.isNotEmpty()) byId.removing(previous.user.id) else byId,
- )
+ val newByNum = byNum.removing(num)
+ var newCandidatesById = candidateNumsById
+ var newCandidatesByPublicKey = candidateNumsByPublicKey
+ previous.user.id
+ .takeIf { it.isNotEmpty() }
+ ?.let { id -> newCandidatesById = newCandidatesById.removingCandidate(id, num) }
+ nodePublicKeyHint(previous)?.let { key ->
+ newCandidatesByPublicKey = newCandidatesByPublicKey.removingCandidate(key, num)
+ }
+ var newById = byId
+ if (previous.user.id.isNotEmpty()) {
+ val survivor =
+ chooseRepresentativeNum(newCandidatesById[previous.user.id].orEmpty(), newByNum, preferredNum)
+ newById =
+ if (survivor != null) {
+ newById.putting(previous.user.id, checkNotNull(newByNum[survivor]))
+ } else {
+ newById.removing(previous.user.id)
+ }
+ }
+ return NodeIndex(newByNum, newById, newCandidatesById, newCandidatesByPublicKey)
}
companion object {
+ internal fun isDefaultIdentityPlaceholder(node: Node): Boolean = nodePublicKeyHint(node) == null &&
+ node.user.hw_model == HardwareModel.UNSET &&
+ node.user.id == NodeAddress.numToDefaultId(node.num) &&
+ node.user.long_name.matches(DEFAULT_NODE_NAME_REGEX)
+
+ /**
+ * Selects a deterministic representative number from [candidates] sharing one user ID. If [preferredNum] is
+ * present it wins; otherwise an established key/hardware identity wins, then the lower node number.
+ */
+ internal fun chooseRepresentativeNum(
+ candidates: Set<Int>,
+ nodes: Map<Int, Node>,
+ preferredNum: Int?,
+ ): Int? {
+ if (preferredNum != null && preferredNum in candidates) return preferredNum
+ return candidates.minWithOrNull(
+ compareByDescending<Int> { num ->
+ nodes[num]?.let { node ->
+ nodePublicKeyHint(node) != null || node.user.hw_model != HardwareModel.UNSET
+ } == true
+ }
+ .thenBy { it },
+ )
+ }
+
fun fromByNum(nodes: Map<Int, Node>): NodeIndex {
- var byNum = persistentMapOf<Int, Node>()
- var byId = persistentMapOf<String, Node>()
- for ((num, node) in nodes) {
- byNum = byNum.putting(num, node)
- if (node.user.id.isNotEmpty()) byId = byId.putting(node.user.id, node)
- }
- return NodeIndex(byNum, byId)
+ var result = NodeIndex()
+ nodes.forEach { (num, node) -> result = result.put(num, node) }
+ return result
+ }
+
+ private fun <K> PersistentMap<K, PersistentSet<Int>>.addingCandidate(
+ key: K,
+ num: Int,
+ ): PersistentMap<K, PersistentSet<Int>> = putting(key, (this[key] ?: persistentSetOf()).adding(num))
+
+ private fun <K> PersistentMap<K, PersistentSet<Int>>.removingCandidate(
+ key: K,
+ num: Int,
+ ): PersistentMap<K, PersistentSet<Int>> {
+ val remaining = this[key]?.removing(num) ?: return this
+ return if (remaining.isEmpty()) removing(key) else putting(key, remaining)
}
}
}
- private val nodeIndex = atomic(NodeIndex())
+ private data class NodeState(
+ val index: NodeIndex = NodeIndex(),
+ val localNodeNum: Int? = null,
+ val retiredNodeNums: PersistentSet<Int> = persistentSetOf(),
+ /**
+ * Validated public-key hint for each retired number, captured before index removal. Used to suppress same-key
+ * replays even when no canonical candidate currently holds the key in the in-memory index.
+ */
+ val retiredKeyHints: PersistentMap<Int, ByteString> = persistentMapOf(),
+ /**
+ * Monotonic counter bumped on every live mutation to [index] or [localNodeNum]. Captured by [loadCachedNodeDB]
+ * before its suspending snapshot read so the commit step can detect concurrent live mutations and merge instead
+ * of overwriting them.
+ */
+ val revision: Long = 0L,
+ ) {
+ fun isRetiredAbsent(nodeNum: Int): Boolean = nodeNum in retiredNodeNums && nodeNum !in index.byNum
+ }
+
+ private val nodeState = atomic(NodeState())
+
+ /**
+ * Session generation, bumped by [clear]. Captured by [loadCachedNodeDB] before its suspending snapshot read so a
+ * load launched in one session can be discarded completely if [clear] started a new session before the load
+ * committed.
+ */
+ private val sessionGeneration = atomic(0L)
override val nodeDBbyNodeNum: Map<Int, Node>
- get() = nodeIndex.value.byNum
+ get() = nodeState.value.index.byNum
- override fun getNodeById(id: String): Node? = nodeIndex.value.byId[id]
+ override fun getNodeById(id: String): Node? = nodeState.value.index.byId[id]
override val isNodeDbReady = MutableStateFlow(false)
override val allowNodeDbWrites = MutableStateFlow(false)
@@ -135,6 +271,7 @@ class NodeManagerImpl(
override val myNodeNum = MutableStateFlow<Int?>(null)
override fun setMyNodeNum(num: Int?) {
+ nodeState.update { it.copy(localNodeNum = num, revision = it.revision + 1) }
myNodeNum.value = num
}
@@ -169,25 +306,72 @@ class NodeManagerImpl(
companion object {
private const val NODE_PERSISTENCE_LANE_COUNT = 64
-
private const val TIME_MS_TO_S = 1000L
+ private const val GENERATED_NODE_NAME_SUFFIX_LENGTH = 4
}
override fun loadCachedNodeDB() {
- // NOTE: this method intentionally does NOT touch _connectionIdentity. The connection-session identity is
- // only populated by a fresh MyNodeInfo handshake (see publishConnectionIdentity); restoring it from cache
- // would re-introduce the stale-identity association bug across transport switches.
+ // NOTE: this method intentionally does NOT touch _connectionIdentity. The connection-session identity is only
+ // populated by a fresh MyNodeInfo handshake; restoring it from cache would re-introduce a stale-identity
+ // association across transport switches.
scope.handledLaunch {
- val nodes = nodeRepository.nodeDBbyNum.first()
- nodeIndex.value = NodeIndex.fromByNum(nodes)
- if (myNodeNum.value == null) {
- myNodeNum.value = nodeRepository.myNodeInfo.value?.myNodeNum
+ // Capture the session generation and live revision BEFORE the suspending DB read. After the read returns
+ // we re-check both: a generation mismatch means clear() started a new session while we were reading, so
+ // the result is discarded completely; a revision mismatch means a live packet mutated the index while we
+ // were reading, so we merge instead of overwriting (otherwise a node received during the load window would
+ // be silently dropped).
+ val generation = sessionGeneration.value
+ val revisionAtStart = nodeState.value.revision
+ // Read from the CURRENTLY SELECTED database through DatabaseProvider.withReadDb, not from the
+ // process-wide nodeDBbyNum StateFlow. The StateFlow is a stateIn cache over SharingStarted.Eagerly and
+ // can briefly retain the PREVIOUS database's map after a currentDb switch, which would resurrect retired
+ // or stale rows on the new session.
+ val snapshot = nodeRepository.getNodeDbSnapshot()
+ val persistedLocalNum = nodeRepository.myNodeInfo.value?.myNodeNum
+ nodeState.update { state ->
+ if (generation != sessionGeneration.value) {
+ // Session was reset while the load was in flight; drop the result entirely.
+ state
+ } else {
+ val retiredNums = state.retiredNodeNums
+ // Retirement always wins: rows for currently retired numbers never re-enter the live index from
+ // the snapshot, even if the cache still holds them.
+ val filteredSnapshot = snapshot.filterKeys { it !in retiredNums }
+ if (state.revision == revisionAtStart) {
+ // No live mutation since capture: install the filtered snapshot directly. Preserve any
+ // local-node number learned during the session; fall back to the persisted value only when
+ // we have none yet.
+ state.copy(
+ index = NodeIndex.fromByNum(filteredSnapshot),
+ localNodeNum = state.localNodeNum ?: persistedLocalNum,
+ )
+ } else {
+ // Live state changed during the load window. Merge: current nodes (with their fresher
+ // fields) win over snapshot rows at the same num; snapshot rows only fill slots that the
+ // live index does not already carry. Live local-node info and retirement state remain
+ // authoritative; a missing local number receives the same persisted fallback as a direct load.
+ val liveByNum = state.index.byNum
+ val merged = filteredSnapshot.toMutableMap()
+ liveByNum.forEach { (num, node) -> merged[num] = node }
+ state.copy(
+ index = NodeIndex.fromByNum(merged),
+ localNodeNum = state.localNodeNum ?: persistedLocalNum,
+ )
+ }
+ }
}
+ // Keep the published myNodeNum consistent with whatever state survived the commit above. If the load was
+ // discarded, the published value is whatever clear()/setMyNodeNum() left behind.
+ myNodeNum.value = nodeState.value.localNodeNum
}
}
override fun clear() {
- nodeIndex.value = NodeIndex()
+ // Bump the session generation FIRST so any in-flight loadCachedNodeDB whose suspend read returns after this
+ // point sees a mismatch and discards its result instead of installing stale state into the freshly cleared
+ // index.
+ sessionGeneration.incrementAndGet()
+ nodeState.value = NodeState()
isNodeDbReady.value = false
allowNodeDbWrites.value = false
myNodeNum.value = null
@@ -198,7 +382,7 @@ class NodeManagerImpl(
override fun getMyNodeInfo(): MyNodeInfo? {
val mi = nodeRepository.myNodeInfo.value ?: return null
- val myNode = nodeIndex.value.byNum[mi.myNodeNum]
+ val myNode = nodeState.value.index.byNum[mi.myNodeNum]
return MyNodeInfo(
myNodeNum = mi.myNodeNum,
hasGPS = (myNode?.position?.latitude_i ?: 0) != 0,
@@ -218,41 +402,70 @@ class NodeManagerImpl(
}
override fun getMyId(): String {
- val num = myNodeNum.value ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return ""
- return nodeIndex.value.byNum[num]?.user?.id ?: ""
+ val num = nodeState.value.localNodeNum ?: nodeRepository.myNodeInfo.value?.myNodeNum ?: return ""
+ return nodeState.value.index.byNum[num]?.user?.id ?: ""
}
override fun removeByNodenum(nodeNum: Int) {
- nodeIndex.update { it.remove(nodeNum) }
- }
-
- internal fun getOrCreateNode(n: Int, channel: Int = 0): Node = nodeIndex.value.byNum[n]
- ?: run {
- val userId = NodeAddress.numToDefaultId(n)
- val defaultUser =
- User(
- id = userId,
- long_name = "Meshtastic ${userId.takeLast(n = 4)}",
- short_name = userId.takeLast(n = 4),
- hw_model = HardwareModel.UNSET,
- )
+ nodeState.update { state -> state.copy(index = state.index.remove(nodeNum), revision = state.revision + 1) }
+ }
- Node(num = n, user = defaultUser, channel = channel)
+ override fun applyTrustedIdentityMigrations(removedNums: Collection<Int>) {
+ if (removedNums.isEmpty()) return
+ var committedHints: Map<Int, ByteString?> = emptyMap()
+ var committedPresentNums: Set<Int> = emptySet()
+ nodeState.update { state ->
+ // Capture validated public-key hints from the current index state before removal. A number that is already
+ // absent (e.g. re-applying the same migration after a previous retirement) has no current node to capture
+ // a key from — its previously captured hint MUST be preserved so same-key replay stays suppressed and a
+ // later genuinely different-keyed device can still claim the slot under the intended contract.
+ committedPresentNums = removedNums.filterTo(mutableSetOf()) { it in state.index.byNum }
+ committedHints =
+ removedNums.associateWith { num ->
+ state.index.byNum[num]?.let(::resolveNodePublicKeyHint) ?: state.retiredKeyHints[num]
+ }
+ state.copy(
+ index = removedNums.fold(state.index) { index, nodeNum -> index.remove(nodeNum) },
+ retiredNodeNums = state.retiredNodeNums.addingAll(removedNums),
+ // Only ADD hints here; never remove a prior hint for an absent number. A subsequent migration that
+ // re-applies the same retirement must preserve the original hint so same-key replay stays suppressed
+ // and a later genuinely different-keyed device can still claim the slot.
+ retiredKeyHints =
+ state.retiredKeyHints.puttingAll(
+ committedHints.mapNotNull { (num, key) -> key?.let { num to it } }.toMap(),
+ ),
+ revision = state.revision + 1,
+ )
+ }
+ // Commit retirement first. A dispatch already in progress will fail its final state revalidation; one that
+ // completed before the commit is removed by this cancellation. Side effects run once, outside the CAS loop.
+ removedNums.forEach { num ->
+ notificationManager.cancel(num)
+ val keyDescription =
+ committedHints[num]?.let(::publicKeyLogFingerprint)
+ ?: if (num in committedPresentNums) "none" else "absent"
+ Logger.d {
+ "[NodeIdentity] trusted-migration removed=$num" + " key=$keyDescription notificationCancelled=true"
+ }
}
+ }
+
+ internal fun getOrCreateNode(n: Int, channel: Int = 0): Node =
+ nodeState.value.index.byNum[n] ?: createDefaultNode(n, channel)
private data class NodeStateChange(val previous: Node, val next: Node)
- private fun updateNodeState(nodeNum: Int, channel: Int, transform: (Node) -> Node): NodeStateChange {
- // Compute the transform against one immutable snapshot and publish it with CAS. The transform may be retried,
- // so it must remain side-effect free; callers perform notifications or other effects only after this returns.
- while (true) {
- val index = nodeIndex.value
- val current = index.byNum[nodeNum] ?: getOrCreateNode(nodeNum, channel)
+ private fun updateNodeState(nodeNum: Int, channel: Int, transform: (Node) -> Node): NodeStateChange? {
+ var change: NodeStateChange? = null
+ nodeState.update { state ->
+ change = null
+ if (state.isRetiredAbsent(nodeNum)) return@update state
+ val current = state.index.byNum[nodeNum] ?: createDefaultNode(nodeNum, channel)
val next = transform(current)
- if (nodeIndex.compareAndSet(index, index.put(nodeNum, next))) {
- return NodeStateChange(previous = current, next = next)
- }
+ change = NodeStateChange(previous = current, next = next)
+ state.copy(index = state.index.put(nodeNum, next), revision = state.revision + 1)
}
+ return change
}
private fun shouldPersist(node: Node): Boolean =
@@ -263,8 +476,8 @@ class NodeManagerImpl(
channel: Int,
session: RadioSessionContext? = null,
transform: (Node) -> Node,
- ): NodeStateChange = updateNodeState(nodeNum, channel, transform).also { change ->
- if (shouldPersist(change.next)) {
+ ): NodeStateChange? = updateNodeState(nodeNum, channel, transform).also { change ->
+ if (change != null && shouldPersist(change.next)) {
radioInterfaceService.launchSessionWork(scope, session) { persistLatestNode(nodeNum) }
}
}
@@ -283,16 +496,13 @@ class NodeManagerImpl(
}
override suspend fun updateNodeAndPersist(nodeNum: Int, channel: Int, transform: (Node) -> Node) {
- val result = updateNodeState(nodeNum, channel, transform).next
+ val result = updateNodeState(nodeNum, channel, transform)?.next ?: return
if (shouldPersist(result)) persistLatestNode(nodeNum)
}
- /**
- * Serializes persistence per node and reads the latest in-memory value inside that lane. A delayed earlier update
- * therefore cannot overwrite a newer state after its repository write resumes.
- */
+ /** Serializes persistence per node and reads the latest in-memory value inside that lane. */
private suspend fun persistLatestNode(nodeNum: Int) = persistenceLane(nodeNum).withLock {
- val latest = nodeIndex.value.byNum[nodeNum] ?: return@withLock
+ val latest = nodeState.value.index.byNum[nodeNum] ?: return@withLock
if (shouldPersist(latest)) nodeRepository.upsert(latest)
}
@@ -303,41 +513,42 @@ class NodeManagerImpl(
manuallyVerified: Boolean,
session: RadioSessionContext?,
) {
- val change =
- updateNodeAndSchedulePersistence(fromNum, channel, session) { node ->
- val shouldPreserve = shouldPreserveExistingUser(node.user, p)
- if (shouldPreserve) {
- node.copy(channel = channel, manuallyVerified = manuallyVerified)
- } else {
- val keyMatch = !node.hasPKC || node.user.public_key == p.public_key
- val newUser = if (keyMatch) p else p.copy(public_key = ByteString.EMPTY)
- node.copy(
- user = newUser,
- publicKey = newUser.public_key,
- channel = channel,
- manuallyVerified = manuallyVerified,
- )
- }
- }
- val shouldNotify =
- change.previous.isUnknownUser &&
- p.hw_model != HardwareModel.UNSET &&
- !shouldPreserveExistingUser(change.previous.user, p)
- if (shouldNotify) {
- val node = change.next
- radioInterfaceService.launchSessionWork(scope, session) {
- notificationManager.dispatch(
- Notification(
- title = getStringSuspend(Res.string.new_node_seen, node.user.short_name),
- message = node.user.long_name,
- category = Notification.Category.NodeEvent,
- id = node.num,
- // Path format must stay in sync with DEEP_LINK_BASE_URI + DeepLinkRouter
- // in core/navigation (avoided as a Gradle dep here to keep core/data free
- // of Compose Navigation libs).
- deepLinkUri = "meshtastic://meshtastic/nodes/${node.num}",
- ),
+ while (true) {
+ val before = nodeState.value
+ val transition =
+ reduceReceivedUser(
+ before.index,
+ fromNum,
+ p,
+ channel,
+ manuallyVerified,
+ before.localNodeNum,
+ before.isRetiredAbsent(fromNum),
+ retiredKeyHint = before.retiredKeyHints[fromNum],
)
+ receivedUserReductionHook?.invoke()
+ val after =
+ before.copy(
+ index = transition.after,
+ retiredNodeNums =
+ transition.unretireNodeNum?.let { before.retiredNodeNums.removing(it) }
+ ?: before.retiredNodeNums,
+ retiredKeyHints =
+ transition.unretireNodeNum?.let { before.retiredKeyHints.removing(it) }
+ ?: before.retiredKeyHints,
+ revision = before.revision + 1,
+ )
+ if (nodeState.compareAndSet(before, after)) {
+ Logger.d {
+ val keyStr = resolveValidatedPublicKeyHint(p.public_key)?.let(::publicKeyLogFingerprint) ?: "none"
+ val canonicalNum = resolveValidatedPublicKeyHint(p.public_key)?.let { it.crc32().toInt() }
+ "[NodeIdentity] user from=$fromNum local=${before.localNodeNum}" +
+ " retired=${before.isRetiredAbsent(fromNum)}" +
+ " key=$keyStr canonical=$canonicalNum" +
+ " decision=${transition.decision} notify=${transition.notifyNode != null}"
+ }
+ applyReceivedUserEffects(transition, session)
+ return
}
}
}
@@ -413,8 +624,7 @@ class NodeManagerImpl(
node.copy(nodeStatus = status?.takeIf { it.isNotEmpty() })
override fun installNodeInfo(info: ProtoNodeInfo) {
- // Stage-2 configuration installation persists the full node snapshot through NodeRepository.installConfig.
- // Keep this primitive in-memory so that handshake installation does not issue one redundant write per node.
+ // Stage-2 configuration installation persists the complete node snapshot through installConfig.
updateNodeState(info.num, channel = 0) { node -> applyNodeInfo(node, info) }
}
@@ -449,19 +659,407 @@ class NodeManagerImpl(
}
override fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata, session: RadioSessionContext?) {
- radioInterfaceService.launchSessionWork(scope, session) { nodeRepository.insertMetadata(nodeNum, metadata) }
+ if (nodeState.value.isRetiredAbsent(nodeNum)) return
+ radioInterfaceService.launchSessionWork(scope, session) {
+ if (!nodeState.value.isRetiredAbsent(nodeNum)) nodeRepository.insertMetadata(nodeNum, metadata)
+ }
}
private fun shouldPreserveExistingUser(existing: User, incoming: User): Boolean {
- val isDefaultName = (incoming.long_name).matches(Regex("^Meshtastic [0-9a-fA-F]{4}$"))
+ val isDefaultName = incoming.long_name.matches(DEFAULT_NODE_NAME_REGEX)
val isDefaultHwModel = incoming.hw_model == HardwareModel.UNSET
- val hasExistingUser = (existing.id).isNotEmpty() && existing.hw_model != HardwareModel.UNSET
+ val hasCustomIdentity =
+ existing.id != incoming.id ||
+ existing.long_name != incoming.long_name ||
+ existing.short_name != incoming.short_name
+ val hasExistingUser =
+ existing.id.isNotEmpty() && (existing.hw_model != HardwareModel.UNSET || hasCustomIdentity)
return hasExistingUser && isDefaultName && isDefaultHwModel
}
+ /**
+ * Classification of one received-user reduction. Returned by [reduceReceivedUser] so callers can emit a single
+ * identity-routing log line per committed transition.
+ */
+ private enum class ReceivedUserDecision {
+ LOCAL_UPDATE,
+ KNOWN_SAME_NUMBER,
+ GENUINE_NEW_NODE,
+ RETIRED_NO_KEY_SUPPRESSED,
+ RETIRED_WITHOUT_HINT_SUPPRESSED,
+ RETIRED_SAME_KEY_SUPPRESSED,
+ RETIRED_KEY_ALREADY_REPRESENTED,
+ RETIRED_DIFFERENT_KEY_REACTIVATED,
+ CANONICAL_DUPLICATE_RECONCILED,
+ STALE_PRESENTATION_REMOVED,
+ AMBIGUOUS_DUPLICATE_UPDATED,
+ IDENTITY_CONFLICT_IGNORED,
+ }
+
+ /**
+ * Result of classifying one [User] packet against an in-memory [NodeIndex] snapshot. The reducer is pure: it only
+ * reads [before] and the packet fields, and produces the next index plus the side effects to apply once the CAS
+ * commits. Safe to re-evaluate on every retry.
+ */
+ private data class ReceivedUserTransition(
+ val after: NodeIndex,
+ /** Ordinary same-number upsert to apply after CAS; duplicate correlation never supplies this value. */
+ val upsertNode: Node?,
+ /** Node to fire a "new node seen" notification for (null if not a genuine new node). */
+ val notifyNode: Node?,
+ /** Retired number to reactivate after a validated, genuinely new identity claims the vacant slot. */
+ val unretireNodeNum: Int? = null,
+ val decision: ReceivedUserDecision,
+ )
+
+ /**
+ * Pure reducer that classifies an incoming [User] packet against the [before] snapshot and returns the next index
+ * plus queued side effects. No logging, no DB calls, no coroutine launches — deterministic and safe to re-run on
+ * CAS retry.
+ *
+ * A validated public key is only an in-memory correlation hint. It can choose/suppress duplicate presentations but
+ * cannot authorize repository deletion, metadata removal, DM remapping, or durable node-number migration. Those
+ * operations remain exclusively in the trusted config-install DAO path.
+ */
+ @Suppress("CyclomaticComplexMethod", "ReturnCount", "LongMethod")
+ private fun reduceReceivedUser(
+ before: NodeIndex,
+ fromNum: Int,
+ p: User,
+ channel: Int,
+ manuallyVerified: Boolean,
+ myNum: Int?,
+ retiredAbsent: Boolean,
+ retiredKeyHint: ByteString? = null,
+ ): ReceivedUserTransition {
+ val resolvedKey = resolveValidatedPublicKeyHint(p.public_key)
+ val existing = before.byNum[fromNum]
+
+ if (retiredAbsent) {
+ // Suppress same-key replay even when canonical representative hasn't yet appeared in
+ // the in-memory index. The retiredKeyHint was captured from the node as it was retired.
+ if (resolvedKey != null && retiredKeyHint != null && resolvedKey == retiredKeyHint) {
+ return ReceivedUserTransition(
+ after = before,
+ upsertNode = null,
+ notifyNode = null,
+ decision = ReceivedUserDecision.RETIRED_SAME_KEY_SUPPRESSED,
+ )
+ }
+ // A packet with no valid key is suppressed for a retired number.
+ if (resolvedKey == null) {
+ return ReceivedUserTransition(
+ after = before,
+ upsertNode = null,
+ notifyNode = null,
+ decision = ReceivedUserDecision.RETIRED_NO_KEY_SUPPRESSED,
+ )
+ }
+ // A retired slot without a trusted old-key hint has no evidence that a different-looking packet is a
+ // legitimate reuse rather than the stale identity. Keep it retired for this process session.
+ if (retiredKeyHint == null) {
+ return ReceivedUserTransition(
+ after = before,
+ upsertNode = null,
+ notifyNode = null,
+ decision = ReceivedUserDecision.RETIRED_WITHOUT_HINT_SUPPRESSED,
+ )
+ }
+ // Key is already represented elsewhere — stale replay, suppress.
+ val keyAlreadyRepresented =
+ resolvedKey.let { key -> before.candidateNumsByPublicKey[key].orEmpty().isNotEmpty() }
+ if (keyAlreadyRepresented) {
+ return ReceivedUserTransition(
+ after = before,
+ upsertNode = null,
+ notifyNode = null,
+ decision = ReceivedUserDecision.RETIRED_KEY_ALREADY_REPRESENTED,
+ )
+ }
+ // Different valid, unrepresented key — legitimate number reuse.
+ return sameNumberUserTransition(
+ before = before,
+ fromNum = fromNum,
+ existing = createDefaultNode(fromNum, channel),
+ incoming = p,
+ channel = channel,
+ manuallyVerified = manuallyVerified,
+ persist = true,
+ allowNotification = true,
+ unretireNodeNum = fromNum,
+ )
+ .copy(decision = ReceivedUserDecision.RETIRED_DIFFERENT_KEY_REACTIVATED)
+ }
+
+ if (fromNum == myNum) {
+ // Local-link data may persist an ordinary update at its current number. Duplicate cleanup is still
+ // in-memory only; trusted installConfig migration owns durable renumbering and app-local state transfer.
+ val otherSameKeyNums =
+ resolvedKey
+ ?.let { key -> before.candidateNumsByPublicKey[key].orEmpty().filter { it != fromNum } }
+ .orEmpty()
+ val afterRemovals = otherSameKeyNums.fold(before) { idx, num -> idx.remove(num) }
+ val localNode = afterRemovals.byNum[fromNum] ?: createDefaultNode(fromNum, channel)
+ val transformed = transformUserNode(localNode, p, channel, manuallyVerified)
+ return ReceivedUserTransition(
+ after = afterRemovals.put(fromNum, transformed, preferredNum = fromNum),
+ upsertNode = transformed,
+ notifyNode = null,
+ decision = ReceivedUserDecision.LOCAL_UPDATE,
+ )
+ }
+
+ if (resolvedKey != null) {
+ val canonicalNum = resolvedKey.crc32().toInt()
+ val existingKey = existing?.let(::resolveNodePublicKeyHint)
+ val otherSameKeyNums = before.candidateNumsByPublicKey[resolvedKey].orEmpty().filter { it != fromNum }
+
+ // Fast path: a known same-number presentation needs no candidate reconciliation when it is already at the
+ // canonical number or has no duplicate. A canonical duplicate remains in-memory-only because an ordinary
+ // DAO upsert could remap the row through same-key verification.
+ if (existingKey == resolvedKey && (fromNum == canonicalNum || otherSameKeyNums.isEmpty())) {
+ return sameNumberUserTransition(
+ before,
+ fromNum,
+ checkNotNull(existing),
+ p,
+ channel,
+ manuallyVerified,
+ persist = otherSameKeyNums.isEmpty(),
+ allowNotification = false,
+ )
+ }
+
+ if (fromNum == canonicalNum && otherSameKeyNums.isNotEmpty()) {
+ val slotAcceptsCanonical =
+ existing == null || existingKey == resolvedKey || NodeIndex.isDefaultIdentityPlaceholder(existing)
+ if (!slotAcceptsCanonical) {
+ return ReceivedUserTransition(
+ after = before,
+ upsertNode = null,
+ notifyNode = null,
+ decision = ReceivedUserDecision.IDENTITY_CONFLICT_IGNORED,
+ )
+ }
+
+ // Canonical presentation may replace its own default placeholder while preserving all non-user fields.
+ // Same-key noncanonical representatives are removed from memory only.
+ val afterRemovals = otherSameKeyNums.fold(before) { index, num -> index.remove(num) }
+ val baseNode = afterRemovals.byNum[fromNum] ?: createDefaultNode(fromNum, channel)
+ val transformed =
+ transformUserNode(baseNode, p, channel, manuallyVerified).let { updated ->
+ if (existing != null && NodeIndex.isDefaultIdentityPlaceholder(existing)) {
+ updated.copy(channel = existing.channel)
+ } else {
+ updated
+ }
+ }
+ return ReceivedUserTransition(
+ after = afterRemovals.put(fromNum, transformed, preferredNum = fromNum),
+ upsertNode = null,
+ notifyNode = null,
+ decision = ReceivedUserDecision.CANONICAL_DUPLICATE_RECONCILED,
+ )
+ }
+
+ if (canonicalNum in otherSameKeyNums) {
+ // A stale remote presentation yields only when its own slot already carries the same validated hint.
+ // An absent slot, no-key telemetry/position placeholder, or different identity is preserved.
+ val after =
+ if (existingKey == resolvedKey) before.remove(fromNum, preferredNum = canonicalNum) else before
+ return ReceivedUserTransition(
+ after = after,
+ upsertNode = null,
+ notifyNode = null,
+ decision = ReceivedUserDecision.STALE_PRESENTATION_REMOVED,
+ )
+ }
+
+ if (otherSameKeyNums.isNotEmpty()) {
+ // Direction is ambiguous among noncanonical presentations. Accept an empty/default fromNum slot so a
+ // legitimate pre-2.8 or fixed-number peer can move between noncanonical numbers without becoming
+ // locked out behind a telemetry/position placeholder. Preserve established different-key identities.
+ val slotAcceptsAmbiguous =
+ existing == null || existingKey == resolvedKey || NodeIndex.isDefaultIdentityPlaceholder(existing)
+ if (!slotAcceptsAmbiguous) {
+ return ReceivedUserTransition(
+ after = before,
+ upsertNode = null,
+ notifyNode = null,
+ decision = ReceivedUserDecision.IDENTITY_CONFLICT_IGNORED,
+ )
+ }
+
+ // Public keys are broadcast, unauthenticated correlation hints. Installing the presentation here is
+ // deliberately in-memory-only: it must never authorize persistence, migration, or deletion. Trusted
+ // config installation remains the sole authority for durable identity changes.
+ val baseNode = existing ?: createDefaultNode(fromNum, channel)
+ val transformed = transformUserNode(baseNode, p, channel, manuallyVerified)
+ return ReceivedUserTransition(
+ after = before.put(fromNum, transformed, preferredNum = fromNum),
+ upsertNode = null,
+ notifyNode = null,
+ decision = ReceivedUserDecision.AMBIGUOUS_DUPLICATE_UPDATED,
+ )
+ }
+ }
+
+ return sameNumberUserTransition(
+ before,
+ fromNum,
+ existing ?: createDefaultNode(fromNum, channel),
+ p,
+ channel,
+ manuallyVerified,
+ persist = true,
+ allowNotification = true,
+ )
+ }
+
+ private fun sameNumberUserTransition(
+ before: NodeIndex,
+ fromNum: Int,
+ existing: Node,
+ incoming: User,
+ channel: Int,
+ manuallyVerified: Boolean,
+ persist: Boolean,
+ allowNotification: Boolean,
+ unretireNodeNum: Int? = null,
+ ): ReceivedUserTransition {
+ val isNewNode = existing.isUnknownUser && incoming.hw_model != HardwareModel.UNSET
+ val shouldPreserve = shouldPreserveExistingUser(existing.user, incoming)
+ val transformed = transformUserNode(existing, incoming, channel, manuallyVerified)
+ val notify = if (allowNotification && isNewNode && !shouldPreserve) transformed else null
+ val decision =
+ when {
+ allowNotification && isNewNode && !shouldPreserve -> ReceivedUserDecision.GENUINE_NEW_NODE
+ else -> ReceivedUserDecision.KNOWN_SAME_NUMBER
+ }
+ return ReceivedUserTransition(
+ after = before.put(fromNum, transformed),
+ upsertNode = transformed.takeIf { persist },
+ notifyNode = notify,
+ unretireNodeNum = unretireNodeNum,
+ decision = decision,
+ )
+ }
+
+ /** Pure factory for a placeholder [Node] at [num]. Kept side-effect-free so the reducer can call it safely. */
+ private fun createDefaultNode(num: Int, channel: Int): Node {
+ val userId = NodeAddress.numToDefaultId(num)
+ return Node(
+ num = num,
+ user =
+ User(
+ id = userId,
+ long_name = "Meshtastic ${userId.takeLast(GENERATED_NODE_NAME_SUFFIX_LENGTH)}",
+ short_name = userId.takeLast(GENERATED_NODE_NAME_SUFFIX_LENGTH),
+ hw_model = HardwareModel.UNSET,
+ ),
+ channel = channel,
+ )
+ }
+
+ /**
+ * Pure transform of an existing [node] under an incoming [User] packet (extracted from the legacy updateNode path).
+ */
+ private fun transformUserNode(node: Node, p: User, channel: Int, manuallyVerified: Boolean): Node {
+ val shouldPreserve = shouldPreserveExistingUser(node.user, p)
+ return if (shouldPreserve) {
+ node.copy(channel = channel, manuallyVerified = manuallyVerified)
+ } else {
+ val incomingKey = resolveValidatedPublicKeyHint(p.public_key)
+ val sanitizedUser = if (incomingKey == null) p.copy(public_key = ByteString.EMPTY) else p
+ // Prefer node.publicKey when valid (the authoritative stored key); fall back to node.user.public_key.
+ val existingKey = resolveNodePublicKeyHint(node)
+ val keyMatch = existingKey == null || existingKey == incomingKey
+ val newUser = if (keyMatch) sanitizedUser else sanitizedUser.copy(public_key = ByteString.EMPTY)
+ node.copy(
+ user = newUser,
+ publicKey = newUser.public_key,
+ channel = channel,
+ manuallyVerified = manuallyVerified,
+ )
+ }
+ }
+
+ /** Applies ordinary same-number persistence and notification side effects once after the reducer CAS commits. */
+ private fun applyReceivedUserEffects(transition: ReceivedUserTransition, session: RadioSessionContext?) {
+ transition.upsertNode?.let { node ->
+ if (shouldPersist(node)) {
+ radioInterfaceService.launchSessionWork(scope, session) { persistLatestNode(node.num) }
+ }
+ }
+ transition.notifyNode?.let { node ->
+ radioInterfaceService.launchSessionWork(scope, session) {
+ // Resolve the display title before validation so the suspending compose-resources call does
+ // not create a window where state can change after identity checks pass.
+ val title = notificationTitleFormatter(node.user.short_name)
+
+ // Revalidate immediately before dispatch: the number may have been retired, cleared, or
+ // replaced by a different identity between the reducer CAS and this coroutine dispatch.
+ val currentState = nodeState.value
+ if (currentState.isRetiredAbsent(node.num)) {
+ Logger.d { "[NodeIdentity] notification-skip num=${node.num} reason=retired" }
+ return@launchSessionWork
+ }
+ val currentNode = currentState.index.byNum[node.num]
+ if (currentNode == null) {
+ Logger.d { "[NodeIdentity] notification-skip num=${node.num} reason=absent" }
+ return@launchSessionWork
+ }
+ if (currentState.localNodeNum == node.num) {
+ Logger.d { "[NodeIdentity] notification-skip num=${node.num} reason=local-node" }
+ return@launchSessionWork
+ }
+ // Identity comparison: prefer validated public-key equality; fall back to user.id.
+ // Reject if one snapshot has a key and the other does not — that is an identity change.
+ val nodeKey = resolveNodePublicKeyHint(node)
+ val currentKey = resolveNodePublicKeyHint(currentNode)
+ val sameIdentity =
+ when {
+ nodeKey != null && currentKey != null -> nodeKey == currentKey
+
+ nodeKey == null && currentKey == null ->
+ node.user.id.isNotEmpty() &&
+ currentNode.user.id.isNotEmpty() &&
+ node.user.id == currentNode.user.id
+
+ else -> false
+ }
+ if (!sameIdentity) {
+ Logger.d { "[NodeIdentity] notification-skip num=${node.num} reason=identity-changed" }
+ return@launchSessionWork
+ }
+ Logger.d { "[NodeIdentity] notification-dispatch num=${node.num}" }
+ notificationManager.dispatch(
+ Notification(
+ title = title,
+ message = node.user.long_name,
+ category = Notification.Category.NodeEvent,
+ id = node.num,
+ deepLinkUri = "meshtastic://meshtastic/nodes/${node.num}",
+ ),
+ )
+ }
+ }
+ }
+
+ /**
+ * Test seam over the notification title; production resolves compose-resources lazily inside the dispatch
+ * coroutine.
+ */
+ internal var notificationTitleFormatter: suspend (String) -> String = { shortName ->
+ getStringSuspend(Res.string.new_node_seen, shortName)
+ }
+
+ /** Test seam invoked after reduction but before CAS to deterministically race a local-node-number update. */
+ internal var receivedUserReductionHook: (() -> Unit)? = null
+
override fun toNodeID(nodeNum: Int): String = if (nodeNum == NodeAddress.NODENUM_BROADCAST) {
NodeAddress.ID_BROADCAST
} else {
- nodeIndex.value.byNum[nodeNum]?.user?.id ?: NodeAddress.numToDefaultId(nodeNum)
+ nodeState.value.index.byNum[nodeNum]?.user?.id ?: NodeAddress.numToDefaultId(nodeNum)
}
}
diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt
index 34e56c2145..fee66a126a 100644
--- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt
@@ -227,6 +227,9 @@ class NodeRepositoryImpl(
override suspend fun getUnknownNodes(): List<Node> =
withContext(dispatchers.io) { nodeInfoReadDataSource.getUnknownNodes().map { it.toModel() } }
+ override suspend fun getNodeDbSnapshot(): Map<Int, Node> =
+ withContext(dispatchers.io) { nodeInfoReadDataSource.getNodeDbSnapshot().mapValues { (_, it) -> it.toModel() } }
+
/** Persists hardware metadata for a node. */
override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) =
withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(MetadataEntity(nodeNum, metadata)) }
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
index c79bf87f95..4d227c9a40 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt
@@ -626,6 +626,37 @@ class MeshConfigFlowManagerImplTest {
verifySuspend { connectionManager.onNodeDbReady() }
}
+ @Test
+ fun `Stage 2 applies trusted migrations before readiness and replay`() = testScope.runTest {
+ val retiredNum = 456
+ val callOrder = mutableListOf<String>()
+ everySuspend { nodeRepository.installConfig(any(), any()) } calls
+ {
+ callOrder.add("installConfig")
+ listOf(retiredNum)
+ }
+ every { nodeManager.applyTrustedIdentityMigrations(any()) } calls { callOrder.add("applyMigrations") }
+ every { nodeManager.setNodeDbReady(true) } calls { callOrder.add("nodeDbReady") }
+ every { nodeManager.setAllowNodeDbWrites(true) } calls { callOrder.add("writesReady") }
+ everySuspend { connectionManager.onNodeDbReady() } calls { callOrder.add("replayReady") }
+
+ handleMyInfo(protoMyNodeInfo)
+ advanceUntilIdle()
+ manager.handleLocalMetadata(metadata)
+ advanceUntilIdle()
+ manager.handleConfigComplete(HandshakeConstants.CONFIG_NONCE)
+ advanceTimeBy(STAGE_TRANSITION_ADVANCE_MS)
+ runCurrent()
+ manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
+ advanceUntilIdle()
+
+ assertEquals(
+ listOf("installConfig", "applyMigrations", "nodeDbReady", "writesReady", "replayReady"),
+ callOrder,
+ )
+ verify { nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum)) }
+ }
+
@Test
fun `Stage 2 complete id ignored when not in ReceivingNodeInfo state`() = testScope.runTest {
manager.handleConfigComplete(HandshakeConstants.NODE_INFO_NONCE)
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt
index 8140f24a5f..d440f8ac40 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerConnectionIdentityTest.kt
@@ -19,6 +19,7 @@ package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.answering.returns
import dev.mokkery.every
+import dev.mokkery.everySuspend
import dev.mokkery.mock
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
@@ -55,6 +56,7 @@ class NodeManagerConnectionIdentityTest {
@BeforeTest
fun setUp() {
+ everySuspend { nodeRepository.getNodeDbSnapshot() } returns emptyMap()
nodeManager = NodeManagerImpl(nodeRepository, notificationManager, radioInterfaceService, testScope)
}
diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt
index fb5f9621ec..74e48fefc5 100644
--- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt
+++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt
@@ -22,7 +22,10 @@ import dev.mokkery.answering.returns
import dev.mokkery.every
import dev.mokkery.everySuspend
import dev.mokkery.matcher.any
+import dev.mokkery.matcher.capture.capture
import dev.mokkery.mock
+import dev.mokkery.verify
+import dev.mokkery.verify.VerifyMode
import dev.mokkery.verify.VerifyMode.Companion.exactly
import dev.mokkery.verifySuspend
import kotlinx.coroutines.CompletableDeferred
@@ -30,20 +33,26 @@ import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import okio.ByteString
import okio.ByteString.Companion.toByteString
+import org.meshtastic.core.common.util.crc32
import org.meshtastic.core.model.MyNodeInfo
import org.meshtastic.core.model.Node
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.NodeRepository
+import org.meshtastic.core.repository.Notification
import org.meshtastic.core.repository.NotificationManager
import org.meshtastic.core.repository.RadioInterfaceService
import org.meshtastic.core.repository.RadioSessionContext
+import org.meshtastic.proto.DeviceMetadata
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.HardwareModel
+import org.meshtastic.proto.Paxcount
+import org.meshtastic.proto.StatusMessage
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
import kotlin.test.BeforeTest
@@ -68,6 +77,10 @@ class NodeManagerImplTest {
@BeforeTest
fun setUp() {
nodeManager = NodeManagerImpl(nodeRepository, notificationManager, radioInterfaceService, testScope)
+ // Override the compose-resources formatter so notification dispatch is deterministic in the
+ // plain-JVM test env (getStringSuspend does not resolve here). Tests that assert "no dispatch"
+ // still hold: the override only changes the title, not whether dispatch fires.
+ nodeManager.notificationTitleFormatter = { shortName -> "New node seen: $shortName" }
}
@Test
@@ -548,4 +561,1850 @@ class NodeManagerImplTest {
val result = nodeManager.getMyId()
assertEquals("!mynode42", result)
}
+
+ // ---------- Atomic identity reconciliation ----------
+ //
+ // Covers the CAS-loop reducer that replaced the legacy read-then-mutate sequence. Each test asserts typed
+ // in-memory state and the permitted side effects (ordinary same-number upsert and notification dispatch).
+
+ private val validPk = ByteArray(32) { (it + 1).toByte() }.toByteString()
+
+ private fun ByteString.canonicalNum(): Int = crc32().toInt()
+
+ private fun ByteString.noncanonicalNum(preferred: Int): Int =
+ if (preferred != canonicalNum()) preferred else preferred + 1
+
+ private fun makeKnownNode(num: Int, pk: ByteString, name: String = "Known"): Node = Node(
+ num = num,
+ user =
+ User(
+ id = "!${num.toString(16)}",
+ long_name = name,
+ short_name = name.take(3),
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = pk,
+ ),
+ publicKey = pk,
+ )
+
+ private fun enableDbWrites() {
+ nodeManager.setNodeDbReady(true)
+ nodeManager.setAllowNodeDbWrites(true)
+ }
+
+ private fun verifyNoRepositoryDeletion() {
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.deleteNode(any()) }
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.deleteNodes(any()) }
+ }
+
+ // ---------- Trusted migration retirement ----------
+
+ @Test
+ fun `retired number rejects delayed field sequence without persistence`() {
+ val retiredNum = validPk.noncanonicalNum(4100)
+ nodeManager.updateNode(retiredNum) { makeKnownNode(retiredNum, validPk, "Pre-migration") }
+ nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum))
+ enableDbWrites()
+
+ nodeManager.handleReceivedTelemetry(retiredNum, Telemetry(device_metrics = DeviceMetrics(battery_level = 50)))
+ nodeManager.handleReceivedPosition(
+ retiredNum,
+ myNodeNum = 9999,
+ p = ProtoPosition(latitude_i = 123, longitude_i = 456),
+ defaultTime = 1000L,
+ )
+ nodeManager.handleReceivedPaxcounter(retiredNum, Paxcount(wifi = 10, ble = 5, uptime = 1000))
+ nodeManager.handleReceivedNodeStatus(retiredNum, StatusMessage(status = "stale"))
+ nodeManager.installNodeInfo(
+ ProtoNodeInfo(
+ num = retiredNum,
+ user =
+ User(
+ id = "!retired",
+ long_name = "Retired",
+ short_name = "RET",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = validPk,
+ ),
+ ),
+ )
+ nodeManager.insertMetadata(retiredNum, DeviceMetadata(firmware_version = "2.7.0"))
+ testScope.advanceUntilIdle()
+
+ assertNull(nodeManager.nodeDBbyNodeNum[retiredNum])
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.insertMetadata(any(), any()) }
+ verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ }
+
+ @Test
+ fun `retired number suppresses keyless and represented-key user replays`() {
+ val retiredNum = validPk.noncanonicalNum(4200)
+ val canonicalNum = validPk.canonicalNum()
+ nodeManager.updateNode(canonicalNum) { makeKnownNode(canonicalNum, validPk, "Canonical") }
+ nodeManager.updateNode(retiredNum) { makeKnownNode(retiredNum, validPk, "Retired") }
+ nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum))
+ enableDbWrites()
+
+ nodeManager.handleReceivedUser(
+ retiredNum,
+ User(id = "!keyless", long_name = "Keyless replay", short_name = "KEY", hw_model = HardwareModel.TLORA_V2),
+ )
+ nodeManager.handleReceivedUser(
+ retiredNum,
+ User(
+ id = "!invalid",
+ long_name = "Invalid-key replay",
+ short_name = "INV",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = byteArrayOf(1, 2, 3).toByteString(),
+ ),
+ )
+ nodeManager.handleReceivedUser(
+ retiredNum,
+ User(
+ id = "!represented",
+ long_name = "Represented replay",
+ short_name = "REP",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = validPk,
+ ),
+ )
+ testScope.advanceUntilIdle()
+
+ assertNull(nodeManager.nodeDBbyNodeNum[retiredNum])
+ assertEquals("Canonical", nodeManager.nodeDBbyNodeNum[canonicalNum]?.user?.long_name)
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
+ verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ }
+
+ @Test
+ fun `retired number accepts validated new-key reuse and un-retires the slot`() {
+ // Seed a trusted old identity at the number FIRST, so the migration retires it with a trusted key hint. Without
+ // the hint, a later key packet would be suppressed as RETIRED_WITHOUT_HINT_SUPPRESSED and the slot would stay
+ // retired for the session (see reduceReceivedUser).
+ val retiredNum = validPk.noncanonicalNum(4300)
+ val replacementKey = ByteArray(32) { (it + 80).toByte() }.toByteString()
+ nodeManager.updateNode(retiredNum) { makeKnownNode(retiredNum, validPk, "Old") }
+ testScope.advanceUntilIdle()
+ nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum))
+ enableDbWrites()
+
+ nodeManager.handleReceivedUser(
+ retiredNum,
+ User(
+ id = "!replacement",
+ long_name = "Replacement",
+ short_name = "NEW",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = replacementKey,
+ ),
+ )
+ testScope.advanceUntilIdle()
+
+ val replacement = nodeManager.nodeDBbyNodeNum[retiredNum]
+ assertNotNull(replacement)
+ assertEquals(replacementKey, replacement.publicKey)
+ assertEquals("Replacement", replacement.user.long_name)
+ verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) }
+ verifySuspend(mode = VerifyMode.exactly(1)) { notificationManager.dispatch(any()) }
+ }
+
+ @Test
+ fun `loadCachedNodeDB preserves retirement and suppresses reloaded stale replay`() {
+ // The repo still holds the retired node's row; a cache reload must keep the trusted retirement and must NOT
+ // resurrect the retired number into the live index, and a later stale replay at that number stays suppressed.
+ val retiredNum = validPk.noncanonicalNum(4500)
+ val retiredNode = makeKnownNode(retiredNum, validPk, "Retired")
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(retiredNum to retiredNode))
+ everySuspend { nodeRepository.getNodeDbSnapshot() } returns mapOf(retiredNum to retiredNode)
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+
+ nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum))
+ enableDbWrites()
+
+ nodeManager.loadCachedNodeDB()
+ testScope.advanceUntilIdle()
+
+ // Retirement survived the reload AND the reloaded row was filtered out of the live index.
+ assertNull(nodeManager.nodeDBbyNodeNum[retiredNum])
+
+ // A stale keyless replay at the retired number is still suppressed after the reload.
+ nodeManager.handleReceivedUser(
+ retiredNum,
+ User(id = "!stale", long_name = "Stale replay", short_name = "STL", hw_model = HardwareModel.TLORA_V2),
+ )
+ testScope.advanceUntilIdle()
+
+ assertNull(nodeManager.nodeDBbyNodeNum[retiredNum])
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
+ verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ }
+
+ @Test
+ fun `clear starts a new session without retired-number suppression`() {
+ val retiredNum = validPk.noncanonicalNum(4400)
+ nodeManager.applyTrustedIdentityMigrations(listOf(retiredNum))
+ nodeManager.handleReceivedTelemetry(retiredNum, Telemetry(device_metrics = DeviceMetrics(battery_level = 10)))
+ assertNull(nodeManager.nodeDBbyNodeNum[retiredNum])
+
+ nodeManager.clear()
+ nodeManager.handleReceivedTelemetry(retiredNum, Telemetry(device_metrics = DeviceMetrics(battery_level = 90)))
+
+ assertEquals(90, nodeManager.nodeDBbyNodeNum[retiredNum]?.deviceMetrics?.battery_level)
+ }
+
+ // 1. Established same-key duplicate
+
+ @Test
+ fun `established same-key stale replay is removed in memory without repository mutation`() {
+ val oldNum = validPk.noncanonicalNum(1000)
+ val newNum = validPk.canonicalNum()
+ nodeManager.updateNode(newNum) { makeKnownNode(newNum, validPk, "Migrated") }
+ nodeManager.updateNode(oldNum) { makeKnownNode(oldNum, validPk, "Stale Established") }
+ enableDbWrites()
+
+ val staleUser =
+ User(
+ id = "!old",
+ long_name = "Stale",
+ short_name = "STL",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = validPk,
+ )
+ nodeManager.handleReceivedUser(oldNum, staleUser)
+ testScope.advanceUntilIdle()
+
+ assertNull(nodeManager.nodeDBbyNodeNum[oldNum])
+ val canonical = nodeManager.nodeDBbyNodeNum[newNum]
+ assertNotNull(canonical)
+ assertEquals("Migrated", canonical!!.user.long_name)
+ assertEquals(validPk, canonical.publicKey)
+ verifyNoRepositoryDeletion()
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
+ verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ }
+
+ // 2. Placeholder duplicate
+
+ @Test
+ fun `stale replay preserves no-key telemetry placeholder and canonical identity`() {
+ val oldNum = validPk.noncanonicalNum(1000)
+ val newNum = validPk.canonicalNum()
+ nodeManager.updateNode(newNum) { makeKnownNode(newNum, validPk, "Migrated") }
+ val placeholderKey = ByteString.EMPTY
+ val defaultId = NodeAddress.numToDefaultId(oldNum)
+ nodeManager.updateNode(oldNum) {
+ Node(
+ num = oldNum,
+ user =
+ User(
+ id = defaultId,
+ long_name = "Meshtastic ${defaultId.takeLast(4)}",
+ short_name = defaultId.takeLast(4),
+ hw_model = HardwareModel.UNSET,
+ public_key = placeholderKey,
+ ),
+ publicKey = placeholderKey,
+ position = ProtoPosition(latitude_i = 123, longitude_i = 456),
+ channel = 7,
+ )
+ }
+ enableDbWrites()
+
+ val staleUser =
+ User(
+ id = "!old",
+ long_name = "Stale",
+ short_name = "STL",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = validPk,
+ )
+ nodeManager.handleReceivedUser(oldNum, staleUser)
+ testScope.advanceUntilIdle()
+
+ val placeholder = nodeManager.nodeDBbyNodeNum[oldNum]
+ assertNotNull(placeholder)
+ assertEquals(defaultId, placeholder.user.id)
+ assertEquals(123, placeholder.position.latitude_i)
+ assertEquals(456, placeholder.position.longitude_i)
+ assertEquals(7, placeholder.channel)
+ val canonical = nodeManager.nodeDBbyNodeNum[newNum]
+ assertNotNull(canonical)
+ assertEquals("Migrated", canonical!!.user.long_name)
+ verifyNoRepositoryDeletion()
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
+ verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ }
+
+ // 3. Different-key conflict
+
+ @Test
+ fun `different-key conflict preserves both nodes and emits no side effects`() {
+ val fromNum = 1000
+ val otherNum = 2000
+ val differentPk = ByteArray(32) { (it + 50).toByte() }.toByteString()
+ nodeManager.updateNode(otherNum) { makeKnownNode(otherNum, validPk, "Other") }
+ nodeManager.updateNode(fromNum) { makeKnownNode(fromNum, differentPk, "Established") }
+ enableDbWrites()
+
+ val incomingUser =
+ User(
+ id = "!stale",
+ long_name = "Stale",
+ short_name = "STL",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = validPk,
+ )
+ nodeManager.handleReceivedUser(fromNum, incomingUser)
+ testScope.advanceUntilIdle()
+
+ val fromNode = nodeManager.nodeDBbyNodeNum[fromNum]
+ assertNotNull(fromNode)
+ assertEquals("Established", fromNode!!.user.long_name)
+ assertEquals(differentPk, fromNode.publicKey)
+ val otherNode = nodeManager.nodeDBbyNodeNum[otherNum]
+ assertNotNull(otherNode)
+ assertEquals("Other", otherNode!!.user.long_name)
+ verifyNoRepositoryDeletion()
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
+ verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ }
+
+ // 4. Multiple same-key matches
+
+ @Test
+ fun `ambiguous noncanonical duplicates update from entry and preserve every candidate`() {
+ val nodeA = validPk.noncanonicalNum(1000)
+ val nodeB = validPk.noncanonicalNum(2000)
+ val fromNum = validPk.noncanonicalNum(3000)
+ nodeManager.updateNode(nodeA) { makeKnownNode(nodeA, validPk, "Alpha") }
+ nodeManager.updateNode(nodeB) { makeKnownNode(nodeB, validPk, "Bravo") }
+ nodeManager.updateNode(fromNum) { makeKnownNode(fromNum, validPk, "Before") }
+ enableDbWrites()
+
+ val incomingUser =
+ User(
+ id = "!incoming",
+ long_name = "Incoming",
+ short_name = "INC",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = validPk,
+ )
+ nodeManager.handleReceivedUser(fromNum, incomingUser)
+ testScope.advanceUntilIdle()
+
+ // No candidate is removed, but future packets keep updating their own established fromNum entry.
+ assertNotNull(nodeManager.nodeDBbyNodeNum[nodeA])
+ assertNotNull(nodeManager.nodeDBbyNodeNum[nodeB])
+ assertEquals("Incoming", nodeManager.nodeDBbyNodeNum[fromNum]?.user?.long_name)
+ verifyNoRepositoryDeletion()
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
+ verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ }
+
+ @Test
+ fun `ambiguous noncanonical renumber installs identity into empty slot in memory`() {
+ val previousNum = validPk.noncanonicalNum(3100)
+ val fromNum = validPk.noncanonicalNum(3200)
+ nodeManager.updateNode(previousNum) { makeKnownNode(previousNum, validPk, "Previous") }
+ enableDbWrites()
+
+ nodeManager.handleReceivedUser(
+ fromNum,
+ User(
+ id = "!incoming",
+ long_name = "Incoming",
+ short_name = "INC",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = validPk,
+ ),
+ )
+ testScope.advanceUntilIdle()
+
+ assertNotNull(nodeManager.nodeDBbyNodeNum[previousNum])
+ val incoming = assertNotNull(nodeManager.nodeDBbyNodeNum[fromNum])
+ assertEquals("Incoming", incoming.user.long_name)
+ assertEquals(validPk, incoming.publicKey)
+ verifyNoRepositoryDeletion()
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
+ verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ }
+
+ @Test
+ fun `ambiguous noncanonical renumber replaces telemetry placeholder without session lockout`() {
+ val previousNum = validPk.noncanonicalNum(3300)
+ val fromNum = validPk.noncanonicalNum(3400)
+ nodeManager.updateNode(previousNum) { makeKnownNode(previousNum, validPk, "Previous") }
+ nodeManager.handleReceivedPosition(
+ fromNum = fromNum,
+ myNodeNum = 9999,
+ p = ProtoPosition(latitude_i = 123, longitude_i = 456),
+ defaultTime = 1000L,
+ )
+ enableDbWrites()
+
+ nodeManager.handleReceivedUser(
+ fromNum,
+ User(
+ id = "!incoming",
+ long_name = "First Identity",
+ short_name = "ONE",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = validPk,
+ ),
+ )
+ nodeManager.handleReceivedUser(
+ fromNum,
+ User(
+ id = "!incoming",
+ long_name = "Updated Identity",
+ short_name = "TWO",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = validPk,
+ ),
+ )
+ testScope.advanceUntilIdle()
+
+ assertNotNull(nodeManager.nodeDBbyNodeNum[previousNum])
+ val incoming = assertNotNull(nodeManager.nodeDBbyNodeNum[fromNum])
+ assertEquals("Updated Identity", incoming.user.long_name)
+ assertEquals(validPk, incoming.publicKey)
+ assertEquals(123, incoming.position.latitude_i)
+ assertEquals(456, incoming.position.longitude_i)
+ verifyNoRepositoryDeletion()
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
+ verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ }
+
+ // 5. Local authoritative renumber
+
+ @Test
+ fun `local authoritative renumber removes old same-key entries in memory upserts local no notification`() {
+ val localNum = 5000
+ val otherNum = 6000
+ nodeManager.setMyNodeNum(localNum)
+ nodeManager.updateNode(otherNum) { makeKnownNode(otherNum, validPk, "Old Same-Key") }
+ enableDbWrites()
+
+ val localUser =
+ User(
+ id = "!local",
+ long_name = "Local Node",
+ short_name = "LCL",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = validPk,
+ )
+ nodeManager.handleReceivedUser(localNum, localUser)
+ testScope.advanceUntilIdle()
+
+ assertNull(nodeManager.nodeDBbyNodeNum[otherNum])
+ val localNode = nodeManager.nodeDBbyNodeNum[localNum]
+ assertNotNull(localNode)
+ assertEquals("Local Node", localNode!!.user.long_name)
+ assertEquals(validPk, localNode.publicKey)
+ verifyNoRepositoryDeletion()
+ verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) }
+ verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ }
+
+ // 6. Genuine new node
+
+ @Test
+ fun `genuine new node fires exactly one upsert and exactly one notification`() {
+ val newNodeNum = 3000
+ val newPk = ByteArray(32) { (it + 100).toByte() }.toByteString()
+ val newUser =
+ User(
+ id = "!newnode",
+ long_name = "New Node",
+ short_name = "NEW",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = newPk,
+ )
+ enableDbWrites()
+
+ val captured = mutableListOf<Notification>()
+ everySuspend { notificationManager.dispatch(capture(captured)) } returns true
+
+ nodeManager.handleReceivedUser(newNodeNum, newUser)
+ testScope.advanceUntilIdle()
+
+ val result = nodeManager.nodeDBbyNodeNum[newNodeNum]
+ assertNotNull(result)
+ assertEquals("New Node", result!!.user.long_name)
+ assertEquals(newPk, result.publicKey)
+ verifyNoRepositoryDeletion()
+ verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) }
+ // Strengthen: capture the dispatched Notification and verify payload + routing fields, not just the call count.
+ assertEquals(1, captured.size)
+ val n = captured.first()
+ assertEquals(newNodeNum, n.id)
+ assertEquals("New Node", n.message)
+ assertEquals(Notification.Category.NodeEvent, n.category)
+ assertEquals("meshtastic://meshtastic/nodes/$newNodeNum", n.deepLinkUri)
+ }
+
+ // 7. Invalid / malformed keys (null, empty, ERROR) are treated as NoMatch
+
+ @Test
+ fun `invalid public keys skip identity matching and update normally`() {
+ val existingNum = 7000
+ val existingPk = ByteArray(32) { (it + 1).toByte() }.toByteString()
+ nodeManager.updateNode(existingNum) { makeKnownNode(existingNum, existingPk, "Canonical") }
+ enableDbWrites()
+
+ // Three packets at a different number without a validated public-key correlation hint.
+ // None of them should match the canonical node at 7000 or trigger stale cleanup.
+ val targetNum = 8000
+
+ // (a) null key
+ nodeManager.handleReceivedUser(
+ targetNum,
+ User(id = "!a", long_name = "A", short_name = "A", hw_model = HardwareModel.TLORA_V2),
+ )
+ testScope.advanceUntilIdle()
+
+ // (b) empty key
+ nodeManager.handleReceivedUser(
+ targetNum,
+ User(
+ id = "!b",
+ long_name = "B",
+ short_name = "B",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = ByteString.EMPTY,
+ ),
+ )
+ testScope.advanceUntilIdle()
+
+ // (c) ERROR key
+ nodeManager.handleReceivedUser(
+ targetNum,
+ User(
+ id = "!c",
+ long_name = "C",
+ short_name = "C",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = Node.ERROR_BYTE_STRING,
+ ),
+ )
+ testScope.advanceUntilIdle()
+
+ // Canonical node untouched (no stale cleanup).
+ assertNotNull(nodeManager.nodeDBbyNodeNum[existingNum])
+ }
+
+ // 8. Same number same key is a normal update, not a cross-number stale
+
+ @Test
+ fun `same number same key updates normally without stale classification`() {
+ val nodeNum = 9000
+ nodeManager.updateNode(nodeNum) { makeKnownNode(nodeNum, validPk, "Original") }
+ enableDbWrites()
+
+ val updatedUser =
+ User(
+ id = "!${nodeNum.toString(16)}",
+ long_name = "Updated",
+ short_name = "UPD",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = validPk,
+ )
+ nodeManager.handleReceivedUser(nodeNum, updatedUser)
+ testScope.advanceUntilIdle()
+
+ val result = nodeManager.nodeDBbyNodeNum[nodeNum]
+ assertNotNull(result)
+ assertEquals("Updated", result!!.user.long_name)
+ assertEquals(validPk, result.publicKey)
+ verifyNoRepositoryDeletion()
+ verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) }
+ }
+
+ // 9. Packet-driven duplicate cleanup stays entirely in memory.
+
+ @Test
+ fun `stale duplicate never deletes or upserts a repository row`() {
+ val oldNum = validPk.noncanonicalNum(1000)
+ val newNum = validPk.canonicalNum()
+ nodeManager.updateNode(newNum) { makeKnownNode(newNum, validPk, "Canonical") }
+ nodeManager.updateNode(oldNum) { makeKnownNode(oldNum, validPk, "Stale Dup") }
+ enableDbWrites()
+
+ val staleUser =
+ User(
+ id = "!old",
+ long_name = "Stale Packet",
+ short_name = "STL",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = validPk,
+ )
+ nodeManager.handleReceivedUser(oldNum, staleUser)
+ testScope.advanceUntilIdle()
+
+ assertNull(nodeManager.nodeDBbyNodeNum[oldNum])
+ verifyNoRepositoryDeletion()
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
+ }
+
+ // 10. No notification for stale / conflict / local outcomes
+
+ @Test
+ fun `notification never fires for stale conflict or local outcomes`() {
+ // Stale: stale packet at old number with established canonical elsewhere.
+ val staleOld = validPk.noncanonicalNum(1000)
+ val staleCanonical = validPk.canonicalNum()
+ nodeManager.updateNode(staleCanonical) { makeKnownNode(staleCanonical, validPk, "Canonical") }
+ nodeManager.updateNode(staleOld) { makeKnownNode(staleOld, validPk, "Old") }
+ nodeManager.handleReceivedUser(
+ staleOld,
+ User(id = "!s", long_name = "S", short_name = "S", hw_model = HardwareModel.TLORA_V2, public_key = validPk),
+ )
+
+ // Conflict: different established identity at fromNum.
+ val conflictFrom = 3000
+ val conflictOther = 4000
+ val conflictPk = ByteArray(32) { (it + 50).toByte() }.toByteString()
+ nodeManager.updateNode(conflictOther) { makeKnownNode(conflictOther, validPk, "Other") }
+ nodeManager.updateNode(conflictFrom) { makeKnownNode(conflictFrom, conflictPk, "Established") }
+ nodeManager.handleReceivedUser(
+ conflictFrom,
+ User(id = "!c", long_name = "C", short_name = "C", hw_model = HardwareModel.TLORA_V2, public_key = validPk),
+ )
+
+ // Local: fromNum == myNodeNum, local-link authoritative update.
+ val localNum = 5000
+ val localGhost = 6000
+ nodeManager.setMyNodeNum(localNum)
+ nodeManager.updateNode(localGhost) { makeKnownNode(localGhost, validPk, "Ghost") }
+ nodeManager.handleReceivedUser(
+ localNum,
+ User(id = "!l", long_name = "L", short_name = "L", hw_model = HardwareModel.TLORA_V2, public_key = validPk),
+ )
+
+ testScope.advanceUntilIdle()
+
+ // No notification fired for any of the three outcomes.
+ verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ }
+
+ // 11. byId consistency after stale removal — when stale and canonical share a user ID,
+ // removing the stale node must not orphan the canonical node in the byId index.
+
+ @Test
+ fun `byId index points to canonical survivor after stale removal shares user id`() {
+ val oldNum = validPk.noncanonicalNum(1000)
+ val newNum = validPk.canonicalNum()
+ val sharedUserId = "!shared"
+ nodeManager.updateNode(newNum) {
+ Node(
+ num = newNum,
+ user =
+ User(
+ id = sharedUserId,
+ long_name = "Canonical",
+ short_name = "CAN",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = validPk,
+ ),
+ publicKey = validPk,
+ )
+ }
+ nodeManager.updateNode(oldNum) {
+ Node(
+ num = oldNum,
+ user =
+ User(
+ id = sharedUserId,
+ long_name = "Stale",
+ short_name = "STL",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = validPk,
+ ),
+ publicKey = validPk,
+ )
+ }
+ enableDbWrites()
+
+ val staleUser =
+ User(
+ id = sharedUserId,
+ long_name = "Stale",
+ short_name = "STL",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = validPk,
+ )
+ nodeManager.handleReceivedUser(oldNum, staleUser)
+ testScope.advanceUntilIdle()
+
+ assertNull(nodeManager.nodeDBbyNodeNum[oldNum])
+ val survivor = nodeManager.getNodeById(sharedUserId)
+ assertNotNull(survivor)
+ assertEquals(newNum, survivor!!.num)
+ }
+
+ // ── Additional identity reconciliation tests ────────────────────────────────
+
+ // 12. Malformed key lengths (1, 31, 33) are rejected
+
+ @Test
+ fun `malformed key lengths 1 31 33 are rejected and skip identity matching`() {
+ val nodeNum = 1234
+ val shortKey = ByteArray(1) { 1 }.toByteString()
+ val almostKey = ByteArray(31) { 2 }.toByteString()
+ val longKey = ByteArray(33) { 3 }.toByteString()
+
+ // All malformed keys should be treated as no-key (normal update path).
+ nodeManager.handleReceivedUser(
+ nodeNum,
+ User(
+ id = "!short",
+ long_name = "S",
+ short_name = "S",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = shortKey,
+ ),
+ )
+ var result = nodeManager.nodeDBbyNodeNum[nodeNum]
+ assertEquals(ByteString.EMPTY, result!!.publicKey)
+
+ nodeManager.handleReceivedUser(
+ nodeNum,
+ User(
+ id = "!almost",
+ long_name = "A",
+ short_name = "A",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = almostKey,
+ ),
+ )
+ result = nodeManager.nodeDBbyNodeNum[nodeNum]
+ assertEquals(ByteString.EMPTY, result!!.publicKey)
+
+ nodeManager.handleReceivedUser(
+ nodeNum,
+ User(
+ id = "!long",
+ long_name = "L",
+ short_name = "L",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = longKey,
+ ),
+ )
+ result = nodeManager.nodeDBbyNodeNum[nodeNum]
+ assertEquals(ByteString.EMPTY, result!!.publicKey)
+ }
+
+ // 13. Invalid Node.publicKey falls back to valid User.public_key
+
+ @Test
+ fun `invalid Node publicKey falls back to valid User public_key`() {
+ val nodeNum = 1234
+ val validUserKey = ByteArray(32) { 42 }.toByteString()
+ val invalidNodeKey = ByteArray(16) { 1 }.toByteString() // Wrong size
+
+ nodeManager.handleReceivedUser(
+ nodeNum,
+ User(
+ id = "!test",
+ long_name = "Test",
+ short_name = "T",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = validUserKey,
+ ),
+ )
+ // Manually set an invalid Node.publicKey
+ nodeManager.updateNode(nodeNum) { it.copy(publicKey = invalidNodeKey) }
+
+ // Public-key correlation should fall back to User.public_key.
+ val node = nodeManager.nodeDBbyNodeNum[nodeNum]
+ val resolvedKey = nodeManager.resolveNodePublicKeyHint(node!!)
+ assertEquals(validUserKey, resolvedKey)
+ }
+
+ // 14. UNSET hardware with a different valid key is preserved (conflict)
+
+ @Test
+ fun `UNSET hardware with different valid key is preserved as conflict`() {
+ val canonicalNum = 1000
+ val conflictNum = 2000
+ val canonicalKey = ByteArray(32) { 10 }.toByteString()
+ val conflictKey = ByteArray(32) { 20 }.toByteString()
+
+ // Canonical node with established identity
+ nodeManager.updateNode(canonicalNum) { makeKnownNode(canonicalNum, canonicalKey, "Canonical") }
+
+ // Conflict node with UNSET hardware but different valid key
+ nodeManager.updateNode(conflictNum) {
+ Node(
+ num = conflictNum,
+ user =
+ User(
+ id = "!conflict",
+ long_name = "Conflict",
+ short_name = "CON",
+ hw_model = HardwareModel.UNSET,
+ public_key = conflictKey,
+ ),
+ publicKey = conflictKey,
+ )
+ }
+
+ // Packet at conflictNum with canonicalKey should be treated as conflict (preserve both)
+ nodeManager.handleReceivedUser(
+ conflictNum,
+ User(
+ id = "!c",
+ long_name = "C",
+ short_name = "C",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = canonicalKey,
+ ),
+ )
+
+ // Both nodes should still exist
+ assertNotNull(nodeManager.nodeDBbyNodeNum[canonicalNum])
+ assertNotNull(nodeManager.nodeDBbyNodeNum[conflictNum])
+ assertEquals(canonicalKey, nodeManager.nodeDBbyNodeNum[canonicalNum]!!.publicKey)
+ assertEquals(conflictKey, nodeManager.nodeDBbyNodeNum[conflictNum]!!.publicKey)
+ }
+
+ // 15. Custom incomplete identity is preserved
+
+ @Test
+ fun `custom incomplete identity is preserved over default incoming`() {
+ val nodeNum = 1234
+ val customUser =
+ User(
+ id = "!custom",
+ long_name = "My Custom Name",
+ short_name = "MCN",
+ hw_model = HardwareModel.UNSET, // Incomplete hardware
+ )
+ nodeManager.updateNode(nodeNum) { it.copy(user = customUser) }
+
+ // Incoming default user should not overwrite custom identity
+ val defaultUser =
+ User(
+ id = NodeAddress.numToDefaultId(nodeNum),
+ long_name = "Meshtastic ${nodeNum.toHex().takeLast(4)}",
+ short_name = nodeNum.toHex().takeLast(4),
+ hw_model = HardwareModel.UNSET,
+ )
+ nodeManager.handleReceivedUser(nodeNum, defaultUser)
+
+ val result = nodeManager.nodeDBbyNodeNum[nodeNum]
+ assertEquals("My Custom Name", result!!.user.long_name)
+ assertEquals("MCN", result.user.short_name)
+ }
+
+ // 16. Three nodes sharing one user ID
+
+ @Test
+ fun `three nodes sharing one user ID selects deterministic representative`() {
+ val userId = "!shared"
+ val node1 = 1000
+ val node2 = 2000
+ val node3 = 3000
+
+ // All three have the same user ID but different node numbers
+ nodeManager.updateNode(node1) {
+ Node(
+ num = node1,
+ user = User(id = userId, long_name = "N1", short_name = "N1", hw_model = HardwareModel.UNSET),
+ )
+ }
+ nodeManager.updateNode(node2) {
+ Node(
+ num = node2,
+ user = User(id = userId, long_name = "N2", short_name = "N2", hw_model = HardwareModel.TLORA_V2),
+ )
+ }
+ nodeManager.updateNode(node3) {
+ Node(
+ num = node3,
+ user = User(id = userId, long_name = "N3", short_name = "N3", hw_model = HardwareModel.UNSET),
+ )
+ }
+
+ // byId should point to node2 (non-placeholder, lowest among non-placeholders)
+ val representative = nodeManager.getNodeById(userId)
+ assertNotNull(representative)
+ assertEquals(node2, representative!!.num)
+ }
+
+ // 17. Old-ID survivor restoration after put
+
+ @Test
+ fun `old ID survivor restored after put changes user ID`() {
+ val nodeNum = 1234
+ val oldUserId = "!old"
+ val newUserId = "!new"
+ val survivorNum = 5678
+
+ // Two nodes with old user ID
+ nodeManager.updateNode(nodeNum) {
+ Node(
+ num = nodeNum,
+ user = User(id = oldUserId, long_name = "Old", short_name = "OLD", hw_model = HardwareModel.TLORA_V2),
+ )
+ }
+ nodeManager.updateNode(survivorNum) {
+ Node(
+ num = survivorNum,
+ user =
+ User(id = oldUserId, long_name = "Survivor", short_name = "SUR", hw_model = HardwareModel.TLORA_V2),
+ )
+ }
+
+ // Change nodeNum's user ID
+ nodeManager.updateNode(nodeNum) {
+ it.copy(user = it.user.copy(id = newUserId, long_name = "New", short_name = "NEW"))
+ }
+
+ // byId for oldUserId should now point to survivorNum
+ val survivor = nodeManager.getNodeById(oldUserId)
+ assertNotNull(survivor)
+ assertEquals(survivorNum, survivor!!.num)
+
+ // byId for newUserId should point to nodeNum
+ val newNode = nodeManager.getNodeById(newUserId)
+ assertNotNull(newNode)
+ assertEquals(nodeNum, newNode!!.num)
+ }
+
+ // 18. fromByNum insertion-order independence
+
+ @Test
+ fun `fromByNum is independent of insertion order`() {
+ val userId = "!shared"
+ val node1 = 1000
+ val node2 = 2000
+
+ val nodes12 =
+ mapOf(
+ node1 to
+ Node(
+ num = node1,
+ user = User(id = userId, long_name = "N1", short_name = "N1", hw_model = HardwareModel.UNSET),
+ ),
+ node2 to
+ Node(
+ num = node2,
+ user = User(
+ id = userId,
+ long_name = "N2",
+ short_name = "N2",
+ hw_model = HardwareModel.TLORA_V2,
+ ),
+ ),
+ )
+ val nodes21 =
+ mapOf(
+ node2 to
+ Node(
+ num = node2,
+ user = User(
+ id = userId,
+ long_name = "N2",
+ short_name = "N2",
+ hw_model = HardwareModel.TLORA_V2,
+ ),
+ ),
+ node1 to
+ Node(
+ num = node1,
+ user = User(id = userId, long_name = "N1", short_name = "N1", hw_model = HardwareModel.UNSET),
+ ),
+ )
+
+ val index12 = NodeManagerImpl.NodeIndex.fromByNum(nodes12)
+ val index21 = NodeManagerImpl.NodeIndex.fromByNum(nodes21)
+
+ // Both should produce the same byId representative (node2, non-placeholder)
+ assertEquals(index12.byId[userId]!!.num, index21.byId[userId]!!.num)
+ assertEquals(node2, index12.byId[userId]!!.num)
+ }
+
+ // 19. Preferred canonical mapping after stale removal
+
+ @Test
+ fun `preferred canonical remains mapped after stale removal`() {
+ val userId = "!shared"
+ val canonicalKey = ByteArray(32) { 41 }.toByteString()
+ val competingKey = ByteArray(32) { 42 }.toByteString()
+ val preferredNum = canonicalKey.canonicalNum()
+ val competingNum = 500
+ val staleNum = canonicalKey.noncanonicalNum(1000)
+
+ nodeManager.updateNode(competingNum) {
+ Node(
+ num = competingNum,
+ user =
+ User(
+ id = userId,
+ long_name = "Fallback Winner",
+ short_name = "FBK",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = competingKey,
+ ),
+ publicKey = competingKey,
+ )
+ }
+ nodeManager.updateNode(preferredNum) {
+ Node(
+ num = preferredNum,
+ user =
+ User(
+ id = userId,
+ long_name = "Preferred",
+ short_name = "PRE",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = canonicalKey,
+ ),
+ publicKey = canonicalKey,
+ )
+ }
+ nodeManager.updateNode(staleNum) {
+ Node(
+ num = staleNum,
+ user =
+ User(
+ id = userId,
+ long_name = "Stale",
+ short_name = "STL",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = canonicalKey,
+ ),
+ publicKey = canonicalKey,
+ )
+ }
+
+ nodeManager.handleReceivedUser(
+ staleNum,
+ User(
+ id = userId,
+ long_name = "Stale Replay",
+ short_name = "STL",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = canonicalKey,
+ ),
+ )
+
+ assertNull(nodeManager.nodeDBbyNodeNum[staleNum])
+ assertNotNull(nodeManager.nodeDBbyNodeNum[competingNum])
+ assertNotNull(nodeManager.nodeDBbyNodeNum[preferredNum])
+ val representative = nodeManager.getNodeById(userId)
+ assertNotNull(representative)
+ assertEquals(preferredNum, representative!!.num)
+ }
+
+ // 20. Accepted local node remains the byId representative
+
+ @Test
+ fun `accepted local node remains byId representative`() {
+ val userId = "!shared"
+ val remoteNum = 1000
+ val localNum = 2000
+
+ nodeManager.setMyNodeNum(localNum)
+ nodeManager.updateNode(localNum) {
+ Node(
+ num = localNum,
+ user = User(id = userId, long_name = "Local", short_name = "LOC", hw_model = HardwareModel.TLORA_V2),
+ )
+ }
+ nodeManager.updateNode(remoteNum) {
+ Node(
+ num = remoteNum,
+ user = User(id = userId, long_name = "Remote", short_name = "REM", hw_model = HardwareModel.TLORA_V2),
+ )
+ }
+
+ assertEquals(remoteNum, nodeManager.getNodeById(userId)!!.num)
+ nodeManager.handleReceivedUser(
+ localNum,
+ User(id = userId, long_name = "Local Updated", short_name = "LOC", hw_model = HardwareModel.TLORA_V2),
+ )
+
+ assertNotNull(nodeManager.nodeDBbyNodeNum[remoteNum])
+ val representative = nodeManager.getNodeById(userId)
+ assertNotNull(representative)
+ assertEquals(localNum, representative!!.num)
+ }
+
+ // 21. Exact notification title, message, ID, category, and deep link
+
+ @Test
+ fun `notification has exact title message ID category and deep link`() {
+ enableDbWrites()
+ val nodeNum = 1234
+ val user = User(id = "!test", long_name = "Test User", short_name = "TST", hw_model = HardwareModel.TLORA_V2)
+
+ nodeManager.handleReceivedUser(nodeNum, user)
+ testScope.advanceUntilIdle()
+
+ verifySuspend {
+ notificationManager.dispatch(
+ Notification(
+ title = "New node seen: TST",
+ message = "Test User",
+ category = Notification.Category.NodeEvent,
+ id = nodeNum,
+ deepLinkUri = "meshtastic://meshtastic/nodes/$nodeNum",
+ ),
+ )
+ }
+ }
+
+ // 22. Incoming at the canonical num reconciles in memory only and preserves its placeholder history.
+
+ @Test
+ fun `incoming canonical replaces own placeholder and removes noncanonical in memory only`() {
+ val canonicalKey = ByteArray(32) { (it + 7).toByte() }.toByteString()
+ val canonicalNum = canonicalKey.canonicalNum()
+ val staleNum = canonicalKey.noncanonicalNum(7777)
+ // Pre-existing entry at the noncanonical num carries the same key.
+ nodeManager.updateNode(staleNum) { makeKnownNode(staleNum, canonicalKey, "Ghost") }
+ // Placeholder at the canonical num so the incoming slot is "open" for reconciliation.
+ val placeholderId = NodeAddress.numToDefaultId(canonicalNum)
+ nodeManager.updateNode(canonicalNum) {
+ Node(
+ num = canonicalNum,
+ user =
+ User(
+ id = placeholderId,
+ long_name = "Meshtastic ${placeholderId.takeLast(4)}",
+ short_name = placeholderId.takeLast(4),
+ hw_model = HardwareModel.UNSET,
+ ),
+ publicKey = ByteString.EMPTY,
+ position = ProtoPosition(latitude_i = 111, longitude_i = 222),
+ channel = 4,
+ )
+ }
+ enableDbWrites()
+
+ nodeManager.handleReceivedUser(
+ canonicalNum,
+ User(
+ id = "!canonical",
+ long_name = "Canonical",
+ short_name = "CAN",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = canonicalKey,
+ ),
+ )
+ testScope.advanceUntilIdle()
+
+ assertNull(nodeManager.nodeDBbyNodeNum[staleNum])
+ val canonical = nodeManager.nodeDBbyNodeNum[canonicalNum]
+ assertNotNull(canonical)
+ assertEquals("Canonical", canonical!!.user.long_name)
+ assertEquals(canonicalKey, canonical.publicKey)
+ assertEquals(111, canonical.position.latitude_i)
+ assertEquals(222, canonical.position.longitude_i)
+ assertEquals(4, canonical.channel)
+ verifyNoRepositoryDeletion()
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
+ verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ }
+
+ // 23. Incoming canonical absent from index entirely creates from packet, removes noncanonical, no notify.
+
+ @Test
+ fun `incoming canonical absent from index creates from packet removes noncanonical no notify`() {
+ val canonicalKey = ByteArray(32) { (it + 17).toByte() }.toByteString()
+ val canonicalNum = canonicalKey.canonicalNum()
+ val staleNum = canonicalKey.noncanonicalNum(8888)
+ nodeManager.updateNode(staleNum) { makeKnownNode(staleNum, canonicalKey, "Stale Noncanonical") }
+ enableDbWrites()
+
+ nodeManager.handleReceivedUser(
+ canonicalNum,
+ User(
+ id = "!fresh",
+ long_name = "Fresh Canonical",
+ short_name = "FRS",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = canonicalKey,
+ ),
+ )
+ testScope.advanceUntilIdle()
+
+ assertNull(nodeManager.nodeDBbyNodeNum[staleNum])
+ val canonical = nodeManager.nodeDBbyNodeNum[canonicalNum]
+ assertNotNull(canonical)
+ assertEquals("Fresh Canonical", canonical!!.user.long_name)
+ assertEquals(canonicalKey, canonical.publicKey)
+ verifyNoRepositoryDeletion()
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
+ verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ }
+
+ // 24. Neither num canonical preserves both with no side effects.
+
+ @Test
+ fun `neither num canonical preserves both no persistence no notification`() {
+ val key = ByteArray(32) { (it + 23).toByte() }.toByteString()
+ // Pick two nums guaranteed distinct from the canonical num and from each other.
+ val canonicalNum = key.canonicalNum()
+ val a = if (canonicalNum != 1111) 1111 else 1112
+ val b = if (canonicalNum != 2222) 2222 else 2223
+
+ nodeManager.updateNode(a) { makeKnownNode(a, key, "Alpha") }
+ nodeManager.updateNode(b) { makeKnownNode(b, key, "Bravo") }
+ enableDbWrites()
+
+ nodeManager.handleReceivedUser(
+ a,
+ User(
+ id = "!a",
+ long_name = "Alpha Packet",
+ short_name = "ALA",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = key,
+ ),
+ )
+ testScope.advanceUntilIdle()
+
+ // Both are preserved; the incoming packet still updates its own established same-key slot in memory.
+ val alpha = nodeManager.nodeDBbyNodeNum[a]
+ val bravo = nodeManager.nodeDBbyNodeNum[b]
+ assertNotNull(alpha)
+ assertNotNull(bravo)
+ assertEquals("Alpha Packet", alpha!!.user.long_name)
+ assertEquals("Bravo", bravo!!.user.long_name)
+ verifyNoRepositoryDeletion()
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
+ verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ }
+
+ // 25. Different established valid key at the canonical num is preserved as a conflict.
+
+ @Test
+ fun `different established key at canonical num preserved as conflict`() {
+ val canonicalKey = ByteArray(32) { (it + 31).toByte() }.toByteString()
+ val establishedKey = ByteArray(32) { (it + 32).toByte() }.toByteString()
+ val canonicalNum = canonicalKey.canonicalNum()
+ val noncanonicalNum = canonicalKey.noncanonicalNum(3333)
+ nodeManager.updateNode(noncanonicalNum) { makeKnownNode(noncanonicalNum, canonicalKey, "Noncanonical Holder") }
+ // Canonical num is occupied by a node with a DIFFERENT valid key.
+ nodeManager.updateNode(canonicalNum) { makeKnownNode(canonicalNum, establishedKey, "Established") }
+ enableDbWrites()
+
+ nodeManager.handleReceivedUser(
+ canonicalNum,
+ User(
+ id = "!c",
+ long_name = "Claimant",
+ short_name = "CLM",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = canonicalKey,
+ ),
+ )
+ testScope.advanceUntilIdle()
+
+ // Conflict: both preserved with their original keys; no persistence, no notification.
+ val atCanonical = nodeManager.nodeDBbyNodeNum[canonicalNum]
+ val atNoncanonical = nodeManager.nodeDBbyNodeNum[noncanonicalNum]
+ assertNotNull(atCanonical)
+ assertNotNull(atNoncanonical)
+ assertEquals(establishedKey, atCanonical!!.publicKey)
+ assertEquals("Established", atCanonical.user.long_name)
+ assertEquals(canonicalKey, atNoncanonical!!.publicKey)
+ verifyNoRepositoryDeletion()
+ verifySuspend(mode = VerifyMode.not) { nodeRepository.upsert(any()) }
+ verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ }
+
+ // 26. Local classification and the node index share one atomic CAS state.
+
+ @Test
+ fun `local number change during User reduction retries classification atomically`() {
+ val key = ByteArray(32) { (it + 37).toByte() }.toByteString()
+ val previousNum = key.canonicalNum()
+ val newLocalNum = key.noncanonicalNum(9999)
+ nodeManager.updateNode(previousNum) { makeKnownNode(previousNum, key, "Previous") }
+ enableDbWrites()
+ nodeManager.receivedUserReductionHook = {
+ nodeManager.receivedUserReductionHook = null
+ nodeManager.setMyNodeNum(newLocalNum)
+ }
+
+ nodeManager.handleReceivedUser(
+ newLocalNum,
+ User(
+ id = "!local9999",
+ long_name = "Current Local",
+ short_name = "LOC",
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = key,
+ ),
+ )
+ testScope.advanceUntilIdle()
+
+ assertNull(nodeManager.nodeDBbyNodeNum[previousNum])
+ assertEquals("Current Local", nodeManager.nodeDBbyNodeNum[newLocalNum]?.user?.long_name)
+ assertEquals(newLocalNum, nodeManager.myNodeNum.value)
+ verifyNoRepositoryDeletion()
+ verifySuspend(mode = VerifyMode.exactly(1)) { nodeRepository.upsert(any()) }
+ verifySuspend(mode = VerifyMode.not) { notificationManager.dispatch(any()) }
+ }
+
+ @Test
+ fun `reverse ID and public-key indexes stay consistent across put replacement and remove`() {
+ val keyA = ByteArray(32) { 11 }.toByteString()
+ val keyB = ByteArray(32) { 12 }.toByteString()
+ val sharedId = "!shared"
+ val replacementId = "!replacement"
+ var index = NodeManagerImpl.NodeIndex()
+ index = index.put(1, makeKnownNode(1, keyA).copy(user = makeKnownNode(1, keyA).user.copy(id = sharedId)))
+ index = index.put(2, makeKnownNode(2, keyA).copy(user = makeKnownNode(2, keyA).user.copy(id = sharedId)))
+
+ assertEquals<Set<Int>?>(setOf(1, 2), index.candidateNumsById[sharedId])
+ assertEquals<Set<Int>?>(setOf(1, 2), index.candidateNumsByPublicKey[keyA])
+ assertEquals(1, index.byId[sharedId]?.num)
+
+ val replacement = makeKnownNode(1, keyB).copy(user = makeKnownNode(1, keyB).user.copy(id = replacementId))
+ index = index.put(1, replacement)
+ assertEquals<Set<Int>?>(setOf(2), index.candidateNumsById[sharedId])
+ assertEquals<Set<Int>?>(setOf(1), index.candidateNumsById[replacementId])
+ assertEquals<Set<Int>?>(setOf(2), index.candidateNumsByPublicKey[keyA])
+ assertEquals<Set<Int>?>(setOf(1), index.candidateNumsByPublicKey[keyB])
+ assertEquals(2, index.byId[sharedId]?.num)
+ assertEquals(1, index.byId[replacementId]?.num)
+
+ index = index.remove(2)
+ assertNull(index.candidateNumsById[sharedId])
+ assertNull(index.candidateNumsByPublicKey[keyA])
+ assertNull(index.byId[sharedId])
+ assertEquals(1, index.byId[replacementId]?.num)
+ }
+
+ private fun Int.toHex(): String = this.toString(16).padStart(8, '0')
+
+ private fun userWithKey(key: ByteString, longName: String, shortName: String): User = User(
+ id = key.hex(),
+ long_name = longName,
+ short_name = shortName,
+ hw_model = HardwareModel.TLORA_V2,
+ public_key = key,
+ )
+
+ // ---------- Retired-node notification behavior ----------
+
+ @Test
+ fun `applyTrustedIdentityMigrations cancels notifications for each retired number`() = testScope.runTest {
+ // Setup: create nodes with distinct identities
+ val num1 = 1230588578
+ val num2 = 1111111111
+ val key1 = ByteArray(32) { 0x01 }.toByteString()
+ val key2 = ByteArray(32) { 0x02 }.toByteString()
+ nodeManager.handleReceivedUser(num1, userWithKey(key1, "Node1", "N1"), manuallyVerified = false)
+ nodeManager.handleReceivedUser(num2, userWithKey(key2, "Node2", "N2"), manuallyVerified = false)
+ advanceUntilIdle()
+
+ // Act
+ nodeManager.applyTrustedIdentityMigrations(listOf(num1, num2))
+ advanceUntilIdle()
+
+ // Assert
+ verify(VerifyMode.atLeast(1)) { notificationManager.cancel(num1) }
+ verify(VerifyMode.atLeast(1)) { notificationManager.cancel(num2) }
+ }
+
+ @Test
+ fun `trusted migration commits retirement before notification cancellation`() = testScope.runTest {
+ val num = 310000000
+ val key = ByteArray(32) { 0x12 }.toByteString()
+ nodeManager.handleReceivedUser(num, userWithKey(key, "Retiring", "RT"), manuallyVerified = false)
+ advanceUntilIdle()
+ var nodePresentAtCancellation: Boolean? = null
+ every { notificationManager.cancel(num) } calls
+ {
+ nodePresentAtCancellation = num in nodeManager.nodeDBbyNodeNum
+ }
+
+ nodeManager.applyTrustedIdentityMigrations(listOf(num))
+
+ assertEquals(false, nodePresentAtCancellation, "retirement must commit before cancellation side effects")
+ assertNull(nodeManager.nodeDBbyNodeNum[num])
+ verify(VerifyMode.exactly(1)) { notificationManager.cancel(num) }
+ }
+
+ @Test
+ fun `notification skipped when number retired between select and dispatch`() = testScope.runTest {
+ val num = 4000000000.toInt()
+ val key = ByteArray(32) { 0x03 }.toByteString()
+ val titleStarted = CompletableDeferred<Unit>()
+ val releaseTitle = CompletableDeferred<Unit>()
+ val dispatched = mutableListOf<Notification>()
+ everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
+ nodeManager.notificationTitleFormatter = { shortName ->
+ titleStarted.complete(Unit)
+ releaseTitle.await()
+ "New node seen: $shortName"
+ }
+
+ nodeManager.handleReceivedUser(num, userWithKey(key, "NewNode", "NN"), manuallyVerified = false)
+ titleStarted.await()
+ nodeManager.applyTrustedIdentityMigrations(listOf(num))
+ releaseTitle.complete(Unit)
+ advanceUntilIdle()
+
+ assertTrue(dispatched.none { it.id == num && it.message == "NewNode" })
+ }
+
+ @Test
+ fun `notification skipped when number becomes local node before dispatch`() = testScope.runTest {
+ val num = 5000000000.toInt()
+ val key = ByteArray(32) { 0x04 }.toByteString()
+ val titleStarted = CompletableDeferred<Unit>()
+ val releaseTitle = CompletableDeferred<Unit>()
+ val dispatched = mutableListOf<Notification>()
+ everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
+ nodeManager.notificationTitleFormatter = { shortName ->
+ titleStarted.complete(Unit)
+ releaseTitle.await()
+ "New node seen: $shortName"
+ }
+
+ nodeManager.handleReceivedUser(num, userWithKey(key, "NewNode", "NN"), manuallyVerified = false)
+ titleStarted.await()
+ nodeManager.setMyNodeNum(num)
+ releaseTitle.complete(Unit)
+ advanceUntilIdle()
+
+ assertTrue(dispatched.none { it.id == num && it.message == "NewNode" })
+ }
+
+ @Test
+ fun `notification skipped when identity changed before dispatch`() = testScope.runTest {
+ val num = 6000000000.toInt()
+ val key1 = ByteArray(32) { 0x05 }.toByteString()
+ val key2 = ByteArray(32) { 0x06 }.toByteString()
+ val titleStarted = CompletableDeferred<Unit>()
+ val releaseTitle = CompletableDeferred<Unit>()
+ val dispatched = mutableListOf<Notification>()
+ everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
+ nodeManager.notificationTitleFormatter = { shortName ->
+ titleStarted.complete(Unit)
+ releaseTitle.await()
+ "New node seen: $shortName"
+ }
+
+ nodeManager.handleReceivedUser(num, userWithKey(key1, "First", "F1"), manuallyVerified = false)
+ titleStarted.await()
+ nodeManager.updateNode(num) { makeKnownNode(num, key2, "Other") }
+ releaseTitle.complete(Unit)
+ advanceUntilIdle()
+
+ val node = nodeManager.nodeDBbyNodeNum[num]
+ assertEquals("Other", node?.user?.long_name)
+ assertTrue(dispatched.none { it.message == "First" }, "notification for 'First' must be suppressed")
+ }
+
+ @Test
+ fun `retired number with same key suppressed even without canonical key candidate`() = testScope.runTest {
+ val oldNum = 7000000000.toInt()
+ val key = ByteArray(32) { 0x07 }.toByteString()
+ // Create node at oldNum with key, then retire it via migration
+ nodeManager.handleReceivedUser(oldNum, userWithKey(key, "OldNode", "ON"), manuallyVerified = false)
+ advanceUntilIdle()
+ nodeManager.applyTrustedIdentityMigrations(listOf(oldNum))
+ advanceUntilIdle()
+ val replayDispatches = mutableListOf<Notification>()
+ everySuspend { notificationManager.dispatch(capture(replayDispatches)) } returns true
+ // Now replay: the canonical number (crc32(key)) has NOT appeared yet
+ nodeManager.handleReceivedUser(oldNum, userWithKey(key, "Replay", "RP"), manuallyVerified = false)
+ advanceUntilIdle()
+ // The replayed node should NOT appear in nodeDBbyNodeNum at oldNum
+ assertNull(nodeManager.nodeDBbyNodeNum[oldNum])
+ assertTrue(replayDispatches.none { it.id == oldNum })
+ }
+
+ @Test
+ fun `keyless retired number rejects an unrepresented valid key for the session`() = testScope.runTest {
+ val num = 7100000000.toInt()
+ val incomingKey = ByteArray(32) { 0x17 }.toByteString()
+ nodeManager.applyTrustedIdentityMigrations(listOf(num))
+
+ nodeManager.handleReceivedUser(
+ num,
+ userWithKey(incomingKey, "Untrusted Reuse", "UR"),
+ manuallyVerified = false,
+ )
+ advanceUntilIdle()
+
+ assertNull(nodeManager.nodeDBbyNodeNum[num])
+ verifySuspend(mode = VerifyMode.exactly(0)) { notificationManager.dispatch(any()) }
+ }
+
+ @Test
+ fun `retired number with different valid key can be legitimately reused`() = testScope.runTest {
+ val num = 8000000000.toInt()
+ val oldKey = ByteArray(32) { 0x08 }.toByteString()
+ val newKey = ByteArray(32) { 0x09 }.toByteString()
+ // Create and retire with old key
+ nodeManager.handleReceivedUser(num, userWithKey(oldKey, "Old", "OD"), manuallyVerified = false)
+ advanceUntilIdle()
+ nodeManager.applyTrustedIdentityMigrations(listOf(num))
+ advanceUntilIdle()
+ // Capture dispatches to verify the reuse notification specifically,
+ // not the initial sighting from the setup above.
+ val dispatched = mutableListOf<Notification>()
+ everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
+ // Replay with different key — should be allowed as legitimate reuse
+ nodeManager.handleReceivedUser(num, userWithKey(newKey, "New", "NW"), manuallyVerified = false)
+ advanceUntilIdle()
+ val newNode = nodeManager.nodeDBbyNodeNum[num]
+ assertNotNull(newNode)
+ assertEquals("New", newNode.user.long_name)
+ assertEquals(
+ 1,
+ dispatched.count { it.id == num && it.message == "New" },
+ "exactly one notification for the reuse at $num",
+ )
+ }
+
+ @Test
+ fun `legitimate reuse clears prior retirement hint before a later keyless retirement`() = testScope.runTest {
+ val num = 8100000000.toInt()
+ val oldKey = ByteArray(32) { 0x18 }.toByteString()
+ val reuseKey = ByteArray(32) { 0x19 }.toByteString()
+ val untrustedKey = ByteArray(32) { 0x1a }.toByteString()
+ nodeManager.handleReceivedUser(num, userWithKey(oldKey, "Old", "OD"), manuallyVerified = false)
+ advanceUntilIdle()
+ nodeManager.applyTrustedIdentityMigrations(listOf(num))
+ nodeManager.handleReceivedUser(num, userWithKey(reuseKey, "Reuse", "RE"), manuallyVerified = false)
+ advanceUntilIdle()
+ assertEquals("Reuse", nodeManager.nodeDBbyNodeNum[num]?.user?.long_name)
+
+ nodeManager.removeByNodenum(num)
+ nodeManager.applyTrustedIdentityMigrations(listOf(num))
+ nodeManager.handleReceivedUser(num, userWithKey(untrustedKey, "Untrusted", "UN"), manuallyVerified = false)
+ advanceUntilIdle()
+
+ assertNull(nodeManager.nodeDBbyNodeNum[num], "an absent trusted migration must not retain an obsolete hint")
+ }
+
+ @Test
+ fun `public key diagnostics use a one-way fingerprint`() {
+ val key = ByteArray(32) { it.toByte() }.toByteString()
+ val fingerprint = publicKeyLogFingerprint(key)
+
+ assertEquals(key.sha256().hex().take(8), fingerprint)
+ assertFalse(fingerprint == key.hex().take(8), "diagnostics must not expose a raw public-key prefix")
+ }
+
+ @Test
+ fun `complete 2_7 to 2_8 renumbering produces no old number notification`() = testScope.runTest {
+ val oldNum = -297114191
+ val newNum = 1230588578
+ val key = ByteArray(32) { 0x0a }.toByteString()
+ // Simulate: old 2.7 local node identity
+ nodeManager.setMyNodeNum(oldNum)
+ nodeManager.handleReceivedUser(oldNum, userWithKey(key, "OldLocal", "OL"), manuallyVerified = false)
+ advanceUntilIdle()
+ // Trusted migration retires old number, canonical identity moves to newNum
+ nodeManager.setMyNodeNum(newNum)
+ nodeManager.handleReceivedUser(newNum, userWithKey(key, "NewLocal", "NL"), manuallyVerified = false)
+ nodeManager.applyTrustedIdentityMigrations(listOf(oldNum))
+ advanceUntilIdle()
+ // Early replay of old number User packet — should be suppressed
+ val dispatchedBefore = mutableListOf<Notification>()
+ everySuspend { notificationManager.dispatch(capture(dispatchedBefore)) } returns true
+ nodeManager.handleReceivedUser(oldNum, userWithKey(key, "Replay", "RP"), manuallyVerified = false)
+ advanceUntilIdle()
+ // The old number should NOT be in nodeDB
+ assertNull(nodeManager.nodeDBbyNodeNum[oldNum])
+ // No notification should have been dispatched for the old number replay
+ // (the notification dispatch was captured; check none is for oldNum)
+ val oldNumNotifications = dispatchedBefore.filter { it.id == oldNum }
+ assertTrue(oldNumNotifications.isEmpty(), "No notification should be dispatched for retired oldNum")
+ }
+
+ @Test
+ fun `genuine remote first sighting emits exactly one notification`() = testScope.runTest {
+ val num = 9000000000.toInt()
+ val key = ByteArray(32) { 0x0b }.toByteString()
+ nodeManager.setMyNodeNum(1230588578)
+ val dispatched = mutableListOf<Notification>()
+ everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
+ nodeManager.handleReceivedUser(num, userWithKey(key, "Genuine Remote", "GR"), manuallyVerified = false)
+ advanceUntilIdle()
+ val numNotifications = dispatched.filter { it.id == num }
+ assertEquals(1, numNotifications.size, "Exactly one notification for genuine new node")
+ }
+
+ // ── Phase 8 — session-safe node cache loading & trusted-hint survival ─────────
+
+ @Test
+ fun `direct snapshot returns only the newly selected DB after a currentDb switch`() = testScope.runTest {
+ // Simulate the race that motivated Phase 6: the process-wide nodeDBbyNum StateFlow (a stateIn cache) still
+ // holds the PREVIOUS database's node map right after a currentDb switch, but the direct snapshot read
+ // returns
+ // only the newly selected DB's nodes.
+ val staleNum = 1001
+ val freshNum = 2002
+ val staleNode = makeKnownNode(staleNum, validPk, "Old DB")
+ val freshNode = makeKnownNode(freshNum, validPk, "New DB")
+
+ // Stale StateFlow cache (old DB still reflected) vs. the direct snapshot (new DB only).
+ every { nodeRepository.nodeDBbyNum } returns MutableStateFlow(mapOf(staleNum to staleNode))
+ everySuspend { nodeRepository.getNodeDbSnapshot() } returns mapOf(freshNum to freshNode)
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+
+ nodeManager.loadCachedNodeDB()
+ advanceUntilIdle()
+
+ // The cache loader consumed the direct snapshot, so staleNum never entered the live index and freshNum did.
+ assertNull(nodeManager.nodeDBbyNodeNum[staleNum], "stale-DB row must not leak into the live index")
+ val loaded = nodeManager.nodeDBbyNodeNum[freshNum]
+ assertNotNull(loaded)
+ assertEquals("New DB", loaded!!.user.long_name)
+ }
+
+ @Test
+ fun `loadCachedNodeDB result is discarded when clear starts a new session mid-load`() = testScope.runTest {
+ // Pause the load after the snapshot read so we can race a clear() against it before commit. We use the
+ // repository snapshot call as the suspension point.
+ val oldNum = 3003
+ val oldNode = makeKnownNode(oldNum, validPk, "Old")
+ val snapshotRead = CompletableDeferred<Unit>()
+ val snapshotRelease = CompletableDeferred<Unit>()
+ everySuspend { nodeRepository.getNodeDbSnapshot() } calls
+ {
+ snapshotRead.complete(Unit)
+ snapshotRelease.await()
+ mapOf(oldNum to oldNode)
+ }
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+
+ nodeManager.loadCachedNodeDB()
+ snapshotRead.await()
+ // While the load is paused after its read, a new session starts. This must discard the old snapshot.
+ nodeManager.clear()
+ // Add a node AFTER clear so we can verify the discarded load did not wipe it on commit.
+ val liveNum = 4004
+ nodeManager.updateNode(liveNum) { makeKnownNode(liveNum, validPk, "Live After Clear") }
+ snapshotRelease.complete(Unit)
+ advanceUntilIdle()
+
+ // The old-DB snapshot was discarded completely: oldNum is absent, liveNum survives.
+ assertNull(
+ nodeManager.nodeDBbyNodeNum[oldNum],
+ "stale snapshot from the previous session must be discarded",
+ )
+ val live = nodeManager.nodeDBbyNodeNum[liveNum]
+ assertNotNull(live)
+ assertEquals("Live After Clear", live!!.user.long_name)
+ }
+
+ @Test
+ fun `live node update during the load window is not overwritten by the snapshot`() = testScope.runTest {
+ val num = 5005
+ val staleSnapshotNode = makeKnownNode(num, validPk, "Stale Snapshot").copy(lastHeard = 100)
+ val liveUpdatedNode = makeKnownNode(num, validPk, "Live Updated").copy(lastHeard = 200)
+
+ val snapshotRead = CompletableDeferred<Unit>()
+ val snapshotRelease = CompletableDeferred<Unit>()
+ everySuspend { nodeRepository.getNodeDbSnapshot() } calls
+ {
+ snapshotRead.complete(Unit)
+ snapshotRelease.await()
+ mapOf(num to staleSnapshotNode)
+ }
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+
+ nodeManager.loadCachedNodeDB()
+ snapshotRead.await()
+ // A live update arrives between the snapshot capture and its commit. The live value (with fresher fields)
+ // must win over the snapshot row at the same num.
+ nodeManager.updateNode(num) { liveUpdatedNode }
+ snapshotRelease.complete(Unit)
+ advanceUntilIdle()
+
+ val result = nodeManager.nodeDBbyNodeNum[num]
+ assertNotNull(result)
+ assertEquals("Live Updated", result!!.user.long_name)
+ assertEquals(200, result.lastHeard, "live mutation field must not be overwritten by the snapshot")
+ }
+
+ @Test
+ fun `trusted migration during the load window retires absent number and suppresses replay`() = testScope.runTest {
+ val num = 6006
+ val snapshotNode = makeKnownNode(num, validPk, "Pre-migration")
+
+ val snapshotRead = CompletableDeferred<Unit>()
+ val snapshotRelease = CompletableDeferred<Unit>()
+ everySuspend { nodeRepository.getNodeDbSnapshot() } calls
+ {
+ snapshotRead.complete(Unit)
+ snapshotRelease.await()
+ mapOf(num to snapshotNode)
+ }
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(null)
+
+ nodeManager.loadCachedNodeDB()
+ snapshotRead.await()
+ // Trusted migration runs while the load is suspended. The migrated number is absent at this point, so its
+ // hint is captured as "absent" — but the retirement must still survive and suppress a stale replay even
+ // after
+ // the load commits.
+ nodeManager.applyTrustedIdentityMigrations(listOf(num))
+ snapshotRelease.complete(Unit)
+ advanceUntilIdle()
+
+ assertNull(
+ nodeManager.nodeDBbyNodeNum[num],
+ "retired number absent at migration time stays retired post-load",
+ )
+
+ // Stale same-key replay still suppressed, no notification dispatched for the retired old number.
+ val dispatched = mutableListOf<Notification>()
+ everySuspend { notificationManager.dispatch(capture(dispatched)) } returns true
+ nodeManager.handleReceivedUser(num, userWithKey(validPk, "Replay", "RP"), manuallyVerified = false)
+ advanceUntilIdle()
+
+ assertNull(nodeManager.nodeDBbyNodeNum[num])
+ assertTrue(dispatched.none { it.id == num }, "no notification for retired-number replay")
+ }
+
+ @Test
+ fun `persisted local node fills null identity when live nodes change during load`() = testScope.runTest {
+ val persistedNum = 7007
+ val liveNum = 8008
+ val liveKey = ByteArray(32) { (it + 90).toByte() }.toByteString()
+ val persistedInfo =
+ MyNodeInfo(
+ myNodeNum = persistedNum,
+ hasGPS = false,
+ model = null,
+ firmwareVersion = "2.5.0",
+ couldUpdate = false,
+ shouldUpdate = false,
+ currentPacketId = 0L,
+ messageTimeoutMsec = 0,
+ minAppVersion = 0,
+ maxChannels = 0,
+ hasWifi = false,
+ channelUtilization = 0f,
+ airUtilTx = 0f,
+ deviceId = null,
+ )
+ val snapshotRead = CompletableDeferred<Unit>()
+ val snapshotRelease = CompletableDeferred<Unit>()
+ everySuspend { nodeRepository.getNodeDbSnapshot() } calls
+ {
+ snapshotRead.complete(Unit)
+ snapshotRelease.await()
+ mapOf(persistedNum to makeKnownNode(persistedNum, validPk, "Persisted"))
+ }
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(persistedInfo)
+
+ nodeManager.loadCachedNodeDB()
+ snapshotRead.await()
+ nodeManager.updateNode(liveNum) { makeKnownNode(liveNum, liveKey, "Live") }
+ snapshotRelease.complete(Unit)
+ advanceUntilIdle()
+
+ assertEquals(
+ persistedNum,
+ nodeManager.myNodeNum.value,
+ "the merge path must restore persisted local identity",
+ )
+ assertNotNull(nodeManager.nodeDBbyNodeNum[liveNum], "the concurrent live update must also survive")
+ }
+
+ @Test
+ fun `local-node identity learned during the load window survives snapshot commit`() = testScope.runTest {
+ val persistedNum = 7007
+ val persistedInfo =
+ MyNodeInfo(
+ myNodeNum = persistedNum,
+ hasGPS = false,
+ model = null,
+ firmwareVersion = "2.5.0",
+ couldUpdate = false,
+ shouldUpdate = false,
+ currentPacketId = 0L,
+ messageTimeoutMsec = 0,
+ minAppVersion = 0,
+ maxChannels = 0,
+ hasWifi = false,
+ channelUtilization = 0f,
+ airUtilTx = 0f,
+ deviceId = null,
+ )
+ val liveLearnedNum = 8008
+
+ val snapshotRead = CompletableDeferred<Unit>()
+ val snapshotRelease = CompletableDeferred<Unit>()
+ everySuspend { nodeRepository.getNodeDbSnapshot() } calls
+ {
+ snapshotRead.complete(Unit)
+ snapshotRelease.await()
+ // Persisted DB still references the old persistedNum row.
+ mapOf(persistedNum to makeKnownNode(persistedNum, validPk, "Persisted"))
+ }
+ every { nodeRepository.myNodeInfo } returns MutableStateFlow(persistedInfo)
+
+ nodeManager.loadCachedNodeDB()
+ snapshotRead.await()
+ // The live session has learned a DIFFERENT local node number during the load window. The persisted value
+ // must NOT clobber it on commit.
+ nodeManager.setMyNodeNum(liveLearnedNum)
+ snapshotRelease.complete(Unit)
+ advanceUntilIdle()
+
+ assertEquals(
+ liveLearnedNum,
+ nodeManager.myNodeNum.value,
+ "live local-node info wins over persisted snapshot",
+ )
+ }
+
+ @Test
+ fun `repeated trusted migration preserves original hint and still permits distinct-key reuse`() =
+ testScope.runTest {
+ val num = 9009
+ val oldKey = ByteArray(32) { 0x21 }.toByteString()
+ val reuseKey = ByteArray(32) { 0x22 }.toByteString()
+ nodeManager.handleReceivedUser(num, userWithKey(oldKey, "Old", "OD"), manuallyVerified = false)
+ advanceUntilIdle()
+
+ // Apply the same migration twice. The original hint must survive the second pass because the node is
+ // already
+ // retired and absent; the prior hint is preserved rather than dropped.
+ nodeManager.applyTrustedIdentityMigrations(listOf(num))
+ nodeManager.applyTrustedIdentityMigrations(listOf(num))
+ advanceUntilIdle()
+
+ // Same-key replay still suppressed after the repeated migration.
+ val suppressedDispatches = mutableListOf<Notification>()
+ everySuspend { notificationManager.dispatch(capture(suppressedDispatches)) } returns true
+ nodeManager.handleReceivedUser(num, userWithKey(oldKey, "Replay", "RP"), manuallyVerified = false)
+ advanceUntilIdle()
+ assertNull(
+ nodeManager.nodeDBbyNodeNum[num],
+ "same-key replay after repeated migration must stay suppressed",
+ )
+ assertTrue(suppressedDispatches.none { it.id == num })
+
+ // Distinct valid unrepresented key is still accepted as a legitimate reuse, clearing retirement + hint and
+ // emitting exactly one replacement notification.
+ val reuseDispatches = mutableListOf<Notification>()
+ everySuspend { notificationManager.dispatch(capture(reuseDispatches)) } returns true
+ nodeManager.handleReceivedUser(num, userWithKey(reuseKey, "Replacement", "NP"), manuallyVerified = false)
+ advanceUntilIdle()
+
+ val reused = nodeManager.nodeDBbyNodeNum[num]
+ assertNotNull(reused)
+ assertEquals("Replacement", reused!!.user.long_name)
+ assertEquals(reuseKey, reused.publicKey)
+ assertEquals(1, reuseDispatches.count { it.id == num && it.message == "Replacement" })
+ }
}
diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
index bac2ca4e4e..3d29453e07 100644
--- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
+++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt
@@ -257,6 +257,28 @@ interface NodeInfoDao {
>,
>
+ /**
+ * One-shot snapshot of every node row in the CURRENTLY SELECTED database (not the process-wide stateIn cache). Used
+ * by session-safe cache loads (see NodeManagerImpl.loadCachedNodeDB) so a transport switch can't deliver a stale
+ * pre-switch map.
+ */
+ @Query(
+ """
+ SELECT * FROM nodes
+ ORDER BY CASE
+ WHEN num = (SELECT myNodeNum FROM my_node LIMIT 1) THEN 0
+ ELSE 1
+ END,
+ last_heard DESC
+ """,
+ )
+ @Transaction
+ suspend fun nodeDBbyNumSnapshot(): Map<
+ @MapColumn(columnName = "num")
+ Int,
+ NodeWithRelations,
+ >
+
@Query(
"""
WITH OurNode AS (
diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
index b866da6748..15a67bd32c 100644
--- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
+++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt
@@ -189,7 +189,10 @@ data class Node(
private const val DEFAULT_ID_SUFFIX_LENGTH = 4
private const val RELAY_NODE_SUFFIX_MASK = 0xFF
- val ERROR_BYTE_STRING: ByteString = ByteArray(32) { 0 }.toByteString()
+ /** Size (in bytes) of a Curve25519 public key as used by meshtastic firmware. */
+ const val PUBLIC_KEY_SIZE: Int = 32
+
+ val ERROR_BYTE_STRING: ByteString = ByteArray(PUBLIC_KEY_SIZE) { 0 }.toByteString()
fun getRelayNode(relayNodeId: Int, nodes: List<Node>, ourNodeNum: Int?): Node? {
val relayNodeIdSuffix = relayNodeId and RELAY_NODE_SUFFIX_MASK
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt
index 58bd8cfa3c..71d1265c92 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt
@@ -183,6 +183,12 @@ interface NodeManager : NodeIdLookup {
/** Removes a node from the in-memory database by its number. */
fun removeByNodenum(nodeNum: Int)
+ /**
+ * Applies node-number removals returned by the trusted configuration install. Removed numbers stay retired for the
+ * current device/database session so delayed field packets cannot recreate migrated identities.
+ */
+ fun applyTrustedIdentityMigrations(removedNums: Collection<Int>)
+
/**
* Installs node information in memory. Callers that require an immediate standalone write use
* [installNodeInfoAndPersist]; configuration handshakes batch the resulting snapshot through the repository.
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt
index 833f408361..80992d6ac9 100644
--- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt
@@ -121,6 +121,13 @@ interface NodeRepository {
/** Returns all nodes with unknown hardware models. */
suspend fun getUnknownNodes(): List<Node>
+ /**
+ * One-shot snapshot of every node in the CURRENTLY SELECTED database (not the process-wide [nodeDBbyNum] `stateIn`
+ * cache, which can briefly retain the previous transport's map after a switch). Used by session-safe cache loads
+ * that need to read the live DB at invocation time.
+ */
+ suspend fun getNodeDbSnapshot(): Map<Int, Node>
+
/**
* Deletes all nodes from the database.
*
diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt
index f5d79f40ec..a8d590b197 100644
--- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt
+++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt
@@ -286,6 +286,9 @@ class TAKMeshIntegrationTest {
override val myId: StateFlow<String?> = MutableStateFlow(null)
override val localStats: StateFlow<LocalStats> = MutableStateFlow(LocalStats())
override val nodeDBbyNum: StateFlow<Map<Int, Node>> = MutableStateFlow(emptyMap())
+
+ override suspend fun getNodeDbSnapshot(): Map<Int, Node> = nodeDBbyNum.value
+
override val onlineNodeCount: Flow<Int> = MutableStateFlow(0)
override val totalNodeCount: Flow<Int> = MutableStateFlow(0)
diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt
index 3e9b013688..9b05018319 100644
--- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt
+++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt
@@ -131,6 +131,8 @@ class FakeNodeRepository :
override suspend fun getUnknownNodes(): List<Node> = _nodeDBbyNum.value.values.filter { it.isUnknownUser }
+ override suspend fun getNodeDbSnapshot(): Map<Int, Node> = _nodeDBbyNum.value
+
override suspend fun clearNodeDB(preserveFavorites: Boolean) {
if (preserveFavorites) {
_nodeDBbyNum.value = _nodeDBbyNum.value.filter { it.value.isFavorite }
Served by rngit 1.5.2 - Generated in 0.29s